fix: harden run persistence and error surfacing from code review
Snapshot store paths under lock before async runs, roll back failed start/save state, emit UI events only after successful persistence, surface log write failures, and sync stale YAML docs to JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,12 +12,12 @@ import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) string {
|
||||
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) (string, error) {
|
||||
if strings.TrimSpace(logsDir) == "" {
|
||||
return ""
|
||||
return "", errors.New("logs directory is empty")
|
||||
}
|
||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("create logs directory: %w", err)
|
||||
}
|
||||
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||
// by run time. The job name is included for human scanning, but sanitized to
|
||||
@@ -26,9 +27,9 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
|
||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("write log file: %w", err)
|
||||
}
|
||||
return path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const commandTimeout = 30 * time.Second
|
||||
const commandWaitDelay = 2 * time.Second
|
||||
|
||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
|
||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) {
|
||||
started := time.Now()
|
||||
// Commands can hang forever if a script waits for input or a child process
|
||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||
@@ -54,7 +54,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
||||
|
||||
now := time.Now()
|
||||
timestamp := now.Format("2006-01-02 15:04:05")
|
||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
|
||||
logFile, logErr := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
|
||||
|
||||
// The runner is now pure with respect to the job: it returns a RunRecord and
|
||||
// lets the caller fold that record into the job's JobRuntime. Run state no
|
||||
@@ -69,7 +69,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
||||
LogFile: logFile,
|
||||
Output: output,
|
||||
DurationMS: durationMS,
|
||||
}
|
||||
}, logErr
|
||||
}
|
||||
|
||||
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||
|
||||
@@ -27,7 +27,10 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
|
||||
Command: echoCommand("header test output"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||
record, err := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.LogFile == "" {
|
||||
t.Fatal("expected log file to be written")
|
||||
}
|
||||
@@ -74,7 +77,10 @@ func TestRunJobRecordFields(t *testing.T) {
|
||||
Command: echoCommand("record field check"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if record.JobID != job.ID {
|
||||
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
||||
@@ -158,7 +164,10 @@ func TestRunJobWritesLogFile(t *testing.T) {
|
||||
Command: echoCommand("hello from test"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.LogFile == "" {
|
||||
t.Fatal("expected log file path")
|
||||
}
|
||||
@@ -193,7 +202,10 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
@@ -222,7 +234,10 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
||||
Command: scriptPath,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
@@ -244,7 +259,10 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
||||
Arguments: "/C\necho separate arguments ok",
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||
}
|
||||
@@ -267,7 +285,10 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
|
||||
job.Arguments = "/C\nexit /b 1"
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "Failed" {
|
||||
t.Fatalf("expected non-zero exit code to fail, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
@@ -291,7 +312,10 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
||||
StartOnly: true,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
@@ -312,7 +336,10 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
||||
StartOnly: true,
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != "Failed" {
|
||||
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user