From c29d581fb1dacf6000fad1a9c49a5436ef5a42df Mon Sep 17 00:00:00 2001 From: mixeme Date: Thu, 18 Jun 2026 21:47:29 +0300 Subject: [PATCH] T1.3: Create src/runner; move runner logic Move runner.go, runner_windows.go, runner_other.go, runner_test.go from src/core to src/runner (package runner). Split helpers into focused files: invocation*.go, exitcodes.go, logfile.go, cleanup.go. Export LogArguments and SuccessExitCodesText for use by scheduler's runningOutput helper. Update src/core/scheduler.go and src/gui/app.go to import src/runner. Co-Authored-By: Claude Sonnet 4.6 --- docs/REFACTORING.md | 2 +- src/core/runner.go | 342 ------------------ src/core/scheduler.go | 9 +- src/gui/app.go | 3 +- src/runner/cleanup.go | 61 ++++ src/runner/exitcodes.go | 52 +++ src/runner/invocation.go | 68 ++++ .../invocation_other.go} | 3 +- .../invocation_windows.go} | 3 +- src/runner/logfile.go | 55 +++ src/runner/runner.go | 144 ++++++++ src/{core => runner}/runner_test.go | 9 +- 12 files changed, 398 insertions(+), 353 deletions(-) delete mode 100644 src/core/runner.go create mode 100644 src/runner/cleanup.go create mode 100644 src/runner/exitcodes.go create mode 100644 src/runner/invocation.go rename src/{core/runner_other.go => runner/invocation_other.go} (95%) rename src/{core/runner_windows.go => runner/invocation_windows.go} (99%) create mode 100644 src/runner/logfile.go create mode 100644 src/runner/runner.go rename src/{core => runner}/runner_test.go (98%) diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index 415e3a1..51e9ce8 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -246,7 +246,7 @@ Track progress here. Mark tasks complete as they land and pass review. ### Phase 1 — Split flat `core` package - [x] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc - [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow` -- [ ] T1.3 — Create `src/runner`; move runner logic +- [x] T1.3 — Create `src/runner`; move runner logic - [ ] T1.4 — Create `src/scheduler`; move scheduler - [ ] T1.5 — Create `src/storage`; move store/paths - [ ] T1.6 — Create `src/platform/autostart`; move autostart logic diff --git a/src/core/runner.go b/src/core/runner.go deleted file mode 100644 index 6f7fe86..0000000 --- a/src/core/runner.go +++ /dev/null @@ -1,342 +0,0 @@ -package core - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - "unicode" - - "gitea.mixdep.ru/mix/gosentry/src/domain" - "gitea.mixdep.ru/mix/gosentry/src/platform/winproc" -) - -const commandTimeout = 30 * time.Second -const commandWaitDelay = 2 * time.Second - -func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord { - 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 - // scheduler; later it can become a per-job setting without changing the - // runner contract. - runCtx, cancel := context.WithTimeout(ctx, commandTimeout) - defer cancel() - - var stdout bytes.Buffer - var stderr bytes.Buffer - var output string - var state string - var detail string - if job.StartOnly { - invocation := jobInvocation(context.Background(), *job) - state, detail, output = startJobOnly(invocation, *job, started) - } else { - invocation := jobInvocation(runCtx, *job) - command := invocation.command - command.WaitDelay = commandWaitDelay - if invocation.hideWindow { - winproc.ConfigureHiddenWindow(command) - } - command.Stdout = &stdout - command.Stderr = &stderr - - err := command.Run() - duration := time.Since(started).Round(time.Millisecond) - output = formatOutput(stdout.String(), stderr.String()) - state, detail = runStateDetail(err, runCtx.Err(), duration, *job) - } - - now := time.Now() - job.LastRun = now.Format("2006-01-02 15:04:05") - job.LastState = state - job.Output = output - logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now) - - record := domain.RunRecord{ - Time: job.LastRun, - JobID: job.ID, - JobName: job.Name, - Trigger: trigger, - State: state, - Detail: detail, - LogFile: logFile, - Output: output, - } - // Keep a small in-memory history for the currently running GUI. Full command - // output is persisted to files, so retaining every past record in RAM would - // only duplicate data and make long sessions grow without bound. - job.Logs = append([]domain.RunRecord{record}, job.Logs...) - if len(job.Logs) > 50 { - job.Logs = job.Logs[:50] - } - return record -} - -func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { - entries, err := os.ReadDir(logsDir) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - - type logFile struct { - path string - modTime time.Time - } - var logs []logFile - cutoff := time.Now().AddDate(0, 0, -maxAgeDays) - for _, entry := range entries { - // Only GoSentry run logs are managed here. Directories and non-.log files - // are intentionally ignored so the user can keep notes or other artifacts - // in the same folder without the cleanup policy deleting them. - if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") { - continue - } - path := filepath.Join(logsDir, entry.Name()) - info, err := entry.Info() - if err != nil { - continue - } - if maxAgeDays > 0 && info.ModTime().Before(cutoff) { - // Cleanup is best-effort: failing to delete one file should not block - // the scheduler from running future jobs. - _ = os.Remove(path) - continue - } - logs = append(logs, logFile{path: path, modTime: info.ModTime()}) - } - - if maxFiles <= 0 || len(logs) <= maxFiles { - return nil - } - sort.Slice(logs, func(i int, j int) bool { - // Newest files are kept first, then everything after maxFiles is removed. - // This matches the user's expectation that the most recent failures and - // command output remain available for investigation. - return logs[i].modTime.After(logs[j].modTime) - }) - for _, old := range logs[maxFiles:] { - _ = os.Remove(old.path) - } - return nil -} - -func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string { - if strings.TrimSpace(logsDir) == "" { - return "" - } - if err := os.MkdirAll(logsDir, 0o755); err != nil { - return "" - } - // 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 - // avoid characters that are invalid on Windows or awkward on shells. - fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" - path := filepath.Join(logsDir, fileName) - content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n", - started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output) - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - return "" - } - return path -} - -func sanitizeFileName(name string) string { - name = strings.TrimSpace(name) - if name == "" { - return "job" - } - var builder strings.Builder - for _, r := range name { - switch { - case unicode.IsLetter(r), unicode.IsDigit(r): - builder.WriteRune(r) - case r == '-', r == '_': - builder.WriteRune(r) - default: - builder.WriteRune('_') - } - } - result := strings.Trim(builder.String(), "_") - if result == "" { - return "job" - } - return result -} - -func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) { - command := invocation.command - if invocation.hideWindow { - winproc.ConfigureHiddenWindow(command) - } - err := command.Start() - duration := time.Since(started).Round(time.Millisecond) - if err != nil { - return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0) - } - pid := command.Process.Pid - if releaseErr := command.Process.Release(); releaseErr != nil { - return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid) - } - return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid) -} - -func startOnlyOutput(job domain.Job, pid int) string { - var builder strings.Builder - builder.WriteString("status:\n") - if pid > 0 { - builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid)) - } else { - builder.WriteString("Process did not start.\n\n") - } - builder.WriteString("command:\n") - builder.WriteString(job.Command + "\n\n") - builder.WriteString("arguments:\n") - builder.WriteString(logArguments(job.Arguments)) - builder.WriteString("\n\nstart_only:\ntrue") - return builder.String() -} - -func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) { - if err == nil { - return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration) - } - if errors.Is(runErr, context.DeadlineExceeded) { - return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout) - } - if errors.Is(err, exec.ErrWaitDelay) { - return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay) - } - - var exitError *exec.ExitError - if errors.As(err, &exitError) { - exitCode := exitError.ExitCode() - if acceptedExitCode(exitCode, job.SuccessExitCodes) { - return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode) - } - return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job)) - } - return "Failed", fmt.Sprintf("%T: %v", err, err) -} - -func acceptedExitCode(exitCode int, successExitCodes string) bool { - for _, accepted := range parseExitCodes(successExitCodes) { - if exitCode == accepted { - return true - } - } - return false -} - -func parseExitCodes(value string) []int { - value = strings.TrimSpace(value) - if value == "" { - return []int{0} - } - fields := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' - }) - result := make([]int, 0, len(fields)) - seen := map[int]bool{} - for _, field := range fields { - code, err := strconv.Atoi(strings.TrimSpace(field)) - if err != nil || seen[code] { - continue - } - seen[code] = true - result = append(result, code) - } - if len(result) == 0 { - return []int{0} - } - return result -} - -func successExitCodesText(job domain.Job) string { - codes := parseExitCodes(job.SuccessExitCodes) - parts := make([]string, 0, len(codes)) - for _, code := range codes { - parts = append(parts, strconv.Itoa(code)) - } - return strings.Join(parts, ",") -} - -type commandInvocation struct { - command *exec.Cmd - hideWindow bool -} - -func jobInvocation(ctx context.Context, job domain.Job) commandInvocation { - command := strings.TrimSpace(job.Command) - arguments := commandArguments(job.Arguments) - if len(arguments) > 0 || commandPathExists(command) { - return commandInvocation{ - command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...), - hideWindow: false, - } - } - - // Shell mode remains for existing jobs and for commands that intentionally - // use builtins, redirection, variables, or chained command syntax. - return commandInvocation{ - command: shellCommand(ctx, command), - hideWindow: true, - } -} - -func commandArguments(arguments string) []string { - var result []string - for _, line := range strings.FieldsFunc(arguments, func(r rune) bool { - return r == '\n' || r == '\r' - }) { - line = strings.TrimSpace(line) - if line != "" { - result = append(result, line) - } - } - return result -} - -func commandPathExists(command string) bool { - command = unquoteCommandPath(strings.TrimSpace(command)) - if command == "" { - return false - } - info, err := os.Stat(command) - return err == nil && !info.IsDir() -} - -func unquoteCommandPath(command string) string { - return strings.Trim(strings.TrimSpace(command), `"`) -} - -func logArguments(arguments string) string { - if strings.TrimSpace(arguments) == "" { - return "" - } - return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n") -} - -func formatOutput(stdout string, stderr string) string { - stdout = strings.TrimSpace(stdout) - stderr = strings.TrimSpace(stderr) - if stdout == "" { - // Showing an explicit placeholder is clearer than an empty panel in the - // GUI: the user can tell that the command ran but produced no stream data. - stdout = "" - } - if stderr == "" { - stderr = "" - } - return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr -} diff --git a/src/core/scheduler.go b/src/core/scheduler.go index b34ccec..46e4d7a 100644 --- a/src/core/scheduler.go +++ b/src/core/scheduler.go @@ -8,6 +8,7 @@ import ( "time" "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/runner" "github.com/robfig/cron/v3" ) @@ -155,7 +156,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool { _ = s.store.SaveJobs(*s.jobs) go func() { - record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir) + record := runner.RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir) s.mu.Lock() if current := s.findJobByIDLocked(jobCopy.ID); current != nil { @@ -167,7 +168,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool { current.Logs = current.Logs[:50] } s.prepareNextRun(current, time.Now()) - _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) + _ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) _ = s.store.SaveJobs(*s.jobs) } s.mu.Unlock() @@ -197,9 +198,9 @@ func runningOutput(job domain.Job, trigger string, started time.Time) string { builder.WriteString("command:\n") builder.WriteString(job.Command + "\n\n") builder.WriteString("arguments:\n") - builder.WriteString(logArguments(job.Arguments)) + builder.WriteString(runner.LogArguments(job.Arguments)) builder.WriteString("\n\nsuccess_exit_codes:\n") - builder.WriteString(successExitCodesText(job)) + builder.WriteString(runner.SuccessExitCodesText(job)) builder.WriteString("\n\nstart_only:\n") builder.WriteString(fmt.Sprintf("%t", job.StartOnly)) return builder.String() diff --git a/src/gui/app.go b/src/gui/app.go index 8bd1adc..cf45493 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -15,6 +15,7 @@ import ( "gitea.mixdep.ru/mix/gosentry/assets" "gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/runner" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -987,7 +988,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje } // Cleanup runs on settings save so a user who tightens retention limits // sees the new policy take effect right away. - if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { + if err := runner.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { settingsStatus.SetText("Saved, cleanup failed: " + err.Error()) return } diff --git a/src/runner/cleanup.go b/src/runner/cleanup.go new file mode 100644 index 0000000..04571f7 --- /dev/null +++ b/src/runner/cleanup.go @@ -0,0 +1,61 @@ +package runner + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { + entries, err := os.ReadDir(logsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + type logFile struct { + path string + modTime time.Time + } + var logs []logFile + cutoff := time.Now().AddDate(0, 0, -maxAgeDays) + for _, entry := range entries { + // Only GoSentry run logs are managed here. Directories and non-.log files + // are intentionally ignored so the user can keep notes or other artifacts + // in the same folder without the cleanup policy deleting them. + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") { + continue + } + path := filepath.Join(logsDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + if maxAgeDays > 0 && info.ModTime().Before(cutoff) { + // Cleanup is best-effort: failing to delete one file should not block + // the scheduler from running future jobs. + _ = os.Remove(path) + continue + } + logs = append(logs, logFile{path: path, modTime: info.ModTime()}) + } + + if maxFiles <= 0 || len(logs) <= maxFiles { + return nil + } + sort.Slice(logs, func(i int, j int) bool { + // Newest files are kept first, then everything after maxFiles is removed. + // This matches the user's expectation that the most recent failures and + // command output remain available for investigation. + return logs[i].modTime.After(logs[j].modTime) + }) + for _, old := range logs[maxFiles:] { + _ = os.Remove(old.path) + } + return nil +} diff --git a/src/runner/exitcodes.go b/src/runner/exitcodes.go new file mode 100644 index 0000000..9d05433 --- /dev/null +++ b/src/runner/exitcodes.go @@ -0,0 +1,52 @@ +package runner + +import ( + "strconv" + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) + +func acceptedExitCode(exitCode int, successExitCodes string) bool { + for _, accepted := range parseExitCodes(successExitCodes) { + if exitCode == accepted { + return true + } + } + return false +} + +func parseExitCodes(value string) []int { + value = strings.TrimSpace(value) + if value == "" { + return []int{0} + } + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + result := make([]int, 0, len(fields)) + seen := map[int]bool{} + for _, field := range fields { + code, err := strconv.Atoi(strings.TrimSpace(field)) + if err != nil || seen[code] { + continue + } + seen[code] = true + result = append(result, code) + } + if len(result) == 0 { + return []int{0} + } + return result +} + +func SuccessExitCodesText(job domain.Job) string { + codes := parseExitCodes(job.SuccessExitCodes) + parts := make([]string, 0, len(codes)) + for _, code := range codes { + parts = append(parts, strconv.Itoa(code)) + } + return strings.Join(parts, ",") +} + +func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) } diff --git a/src/runner/invocation.go b/src/runner/invocation.go new file mode 100644 index 0000000..b72d513 --- /dev/null +++ b/src/runner/invocation.go @@ -0,0 +1,68 @@ +package runner + +import ( + "context" + "os" + "os/exec" + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) + +type commandInvocation struct { + command *exec.Cmd + hideWindow bool +} + +func jobInvocation(ctx context.Context, job domain.Job) commandInvocation { + command := strings.TrimSpace(job.Command) + arguments := commandArguments(job.Arguments) + if len(arguments) > 0 || commandPathExists(command) { + return commandInvocation{ + command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...), + hideWindow: false, + } + } + + // Shell mode remains for existing jobs and for commands that intentionally + // use builtins, redirection, variables, or chained command syntax. + return commandInvocation{ + command: shellCommand(ctx, command), + hideWindow: true, + } +} + +func commandArguments(arguments string) []string { + var result []string + for _, line := range strings.FieldsFunc(arguments, func(r rune) bool { + return r == '\n' || r == '\r' + }) { + line = strings.TrimSpace(line) + if line != "" { + result = append(result, line) + } + } + return result +} + +func commandPathExists(command string) bool { + command = unquoteCommandPath(strings.TrimSpace(command)) + if command == "" { + return false + } + info, err := os.Stat(command) + return err == nil && !info.IsDir() +} + +func unquoteCommandPath(command string) string { + return strings.Trim(strings.TrimSpace(command), `"`) +} + +func LogArguments(arguments string) string { + if strings.TrimSpace(arguments) == "" { + return "" + } + return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n") +} + +func logArguments(arguments string) string { return LogArguments(arguments) } diff --git a/src/core/runner_other.go b/src/runner/invocation_other.go similarity index 95% rename from src/core/runner_other.go rename to src/runner/invocation_other.go index b2c12e6..43c5b10 100644 --- a/src/core/runner_other.go +++ b/src/runner/invocation_other.go @@ -1,6 +1,6 @@ //go:build !windows -package core +package runner import ( "context" @@ -12,4 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd { // and avoids a hard dependency on a larger shell such as bash. return exec.CommandContext(ctx, "sh", "-c", command) } - diff --git a/src/core/runner_windows.go b/src/runner/invocation_windows.go similarity index 99% rename from src/core/runner_windows.go rename to src/runner/invocation_windows.go index f8764cf..9447509 100644 --- a/src/core/runner_windows.go +++ b/src/runner/invocation_windows.go @@ -1,4 +1,4 @@ -package core +package runner import ( "context" @@ -56,4 +56,3 @@ func startsWithWindowsRootedPath(command string) bool { command[1] == ':' && (command[2] == '\\' || command[2] == '/') } - diff --git a/src/runner/logfile.go b/src/runner/logfile.go new file mode 100644 index 0000000..641886b --- /dev/null +++ b/src/runner/logfile.go @@ -0,0 +1,55 @@ +package runner + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) + +func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string { + if strings.TrimSpace(logsDir) == "" { + return "" + } + if err := os.MkdirAll(logsDir, 0o755); err != nil { + return "" + } + // 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 + // avoid characters that are invalid on Windows or awkward on shells. + fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" + path := filepath.Join(logsDir, fileName) + content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n", + started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "" + } + return path +} + +func sanitizeFileName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "job" + } + var builder strings.Builder + for _, r := range name { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + case r == '-', r == '_': + builder.WriteRune(r) + default: + builder.WriteRune('_') + } + } + result := strings.Trim(builder.String(), "_") + if result == "" { + return "job" + } + return result +} diff --git a/src/runner/runner.go b/src/runner/runner.go new file mode 100644 index 0000000..e49433a --- /dev/null +++ b/src/runner/runner.go @@ -0,0 +1,144 @@ +package runner + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/platform/winproc" +) + +const commandTimeout = 30 * time.Second +const commandWaitDelay = 2 * time.Second + +func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord { + 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 + // scheduler; later it can become a per-job setting without changing the + // runner contract. + runCtx, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + + var output string + var state string + var detail string + if job.StartOnly { + invocation := jobInvocation(context.Background(), *job) + state, detail, output = startJobOnly(invocation, *job, started) + } else { + var stdoutBuf strings.Builder + var stderrBuf strings.Builder + invocation := jobInvocation(runCtx, *job) + command := invocation.command + command.WaitDelay = commandWaitDelay + if invocation.hideWindow { + winproc.ConfigureHiddenWindow(command) + } + command.Stdout = &stdoutBuf + command.Stderr = &stderrBuf + + err := command.Run() + duration := time.Since(started).Round(time.Millisecond) + output = formatOutput(stdoutBuf.String(), stderrBuf.String()) + state, detail = runStateDetail(err, runCtx.Err(), duration, *job) + } + + now := time.Now() + job.LastRun = now.Format("2006-01-02 15:04:05") + job.LastState = state + job.Output = output + logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now) + + record := domain.RunRecord{ + Time: job.LastRun, + JobID: job.ID, + JobName: job.Name, + Trigger: trigger, + State: state, + Detail: detail, + LogFile: logFile, + Output: output, + } + // Keep a small in-memory history for the currently running GUI. Full command + // output is persisted to files, so retaining every past record in RAM would + // only duplicate data and make long sessions grow without bound. + job.Logs = append([]domain.RunRecord{record}, job.Logs...) + if len(job.Logs) > 50 { + job.Logs = job.Logs[:50] + } + return record +} + +func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) { + command := invocation.command + if invocation.hideWindow { + winproc.ConfigureHiddenWindow(command) + } + err := command.Start() + duration := time.Since(started).Round(time.Millisecond) + if err != nil { + return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0) + } + pid := command.Process.Pid + if releaseErr := command.Process.Release(); releaseErr != nil { + return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid) + } + return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid) +} + +func startOnlyOutput(job domain.Job, pid int) string { + var builder strings.Builder + builder.WriteString("status:\n") + if pid > 0 { + builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid)) + } else { + builder.WriteString("Process did not start.\n\n") + } + builder.WriteString("command:\n") + builder.WriteString(job.Command + "\n\n") + builder.WriteString("arguments:\n") + builder.WriteString(logArguments(job.Arguments)) + builder.WriteString("\n\nstart_only:\ntrue") + return builder.String() +} + +func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) { + if err == nil { + return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration) + } + if errors.Is(runErr, context.DeadlineExceeded) { + return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout) + } + if errors.Is(err, exec.ErrWaitDelay) { + return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay) + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + exitCode := exitError.ExitCode() + if acceptedExitCode(exitCode, job.SuccessExitCodes) { + return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode) + } + return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job)) + } + return "Failed", fmt.Sprintf("%T: %v", err, err) +} + +func formatOutput(stdout string, stderr string) string { + stdout = strings.TrimSpace(stdout) + stderr = strings.TrimSpace(stderr) + if stdout == "" { + // Showing an explicit placeholder is clearer than an empty panel in the + // GUI: the user can tell that the command ran but produced no stream data. + stdout = "" + } + if stderr == "" { + stderr = "" + } + return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr +} diff --git a/src/core/runner_test.go b/src/runner/runner_test.go similarity index 98% rename from src/core/runner_test.go rename to src/runner/runner_test.go index d61fa97..aadae4a 100644 --- a/src/core/runner_test.go +++ b/src/runner/runner_test.go @@ -1,4 +1,4 @@ -package core +package runner import ( "context" @@ -13,6 +13,13 @@ import ( "gitea.mixdep.ru/mix/gosentry/src/platform/winproc" ) +func echoCommand(message string) string { + if runtime.GOOS == "windows" { + return "echo " + message + } + return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" +} + func TestRunJobLogFileAllHeaders(t *testing.T) { logsDir := t.TempDir() job := domain.Job{