From 48faddb3bdeebfb9f894015299baf6b8f0f8327d Mon Sep 17 00:00:00 2001 From: mixeme Date: Sat, 25 Jul 2026 23:24:50 +0300 Subject: [PATCH] feat: per-job command timeout with global default Add an optional per-job run timeout following the overlap_policy inherit pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s guard in runner.RunJob. - domain/storage: new fields, default 30, load-time normalization - runner: RunJob takes an explicit timeout; StartOnly stays untimed so it keeps measuring launch latency only - app: effectiveTimeout resolves under mu into runEnv, threaded to runJob; seam signature and validation updated; DisplayTimeout helper - ui: Timeout entry in the job dialog, Default timeout in Settings, and a Timeout row in the details panel - tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated; version bumped to 0.12.0 Co-Authored-By: Claude Opus 4.8 --- docs/ARCHITECTURE.md | 18 ++++++++- docs/CHANGELOG.md | 11 ++++++ docs/ROADMAP.md | 12 ------ docs/STANDARDS.md | 7 ++-- src/app/format.go | 11 ++++++ src/app/format_test.go | 11 ++++++ src/app/operations.go | 6 +++ src/app/operations_test.go | 24 +++++++----- src/app/run.go | 17 ++++++++- src/app/run_test.go | 40 ++++++++++++++------ src/app/service.go | 2 +- src/app/version.go | 2 +- src/domain/config.go | 5 ++- src/domain/job.go | 22 ++++++----- src/runner/runner.go | 16 ++++---- src/runner/runner_test.go | 74 ++++++++++++++++++++++++++++++++----- src/storage/store.go | 5 +++ src/storage/store_test.go | 3 ++ src/ui/job_dialog.go | 21 ++++++++++- src/ui/jobs_view.go | 6 +-- src/ui/jobs_view_details.go | 15 +++++--- src/ui/mainwindow_test.go | 17 +++++---- src/ui/settings_view.go | 12 +++++- 23 files changed, 270 insertions(+), 87 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ddc6ce..a2aa8eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -87,8 +87,9 @@ flowchart LR 5. Command execution: `runner.RunJob` builds the platform-specific invocation, executes the - command through the platform shell, captures stdout and stderr, writes one - timestamped `.log` file, and returns a `domain.RunRecord` containing + command through the platform shell under the caller-supplied timeout, captures + stdout and stderr, writes one timestamped `.log` file, and returns a + `domain.RunRecord` containing `DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly` fire-and-forget jobs it measures launch latency — the time to spawn the process — since there is no exit to wait for). @@ -128,6 +129,19 @@ in flight increments `JobRuntime.PendingRuns`. When the current run finishes, `executeRun` drains the counter by starting one deferred run per completion until `PendingRuns` reaches zero. +### Per-job command timeout + +`domain.Job` carries a `TimeoutSeconds` field (`json:"timeout_seconds,omitempty"`), +following the same inherit pattern as the overlap policy. `0` means inherit the +global `Config.DefaultTimeoutSeconds` (default **30**); a positive value overrides +it for that job alone. `app.Service.effectiveTimeout` resolves the effective +duration under `mu` and `startRunLocked` snapshots it into `runEnv.timeout`. +`runner.RunJob(ctx, job, trigger, logsDir, timeout)` takes the resolved duration +as an argument, so the runner stays ignorant of the global config: it applies the +timeout via `context.WithTimeout` and reports `Timed out after ` on +expiry. `StartOnly` jobs run on the untimed context and so measure launch latency +only, unaffected by the run timeout. + ### Run-time statistics `domain.JobRuntime` holds a rolling aggregate updated after each run: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 476af9e..9afdca0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,17 @@ All notable GoSentry changes are recorded in this file. +## 0.12.0 - 2026-07-25 + +**Per-job command timeout:** +- Each job may now set its own run timeout (seconds) in the job dialog; leaving + it empty inherits a new **Default timeout** in Settings (default 30s), the same + inherit pattern as the overlap policy. The details panel shows the effective + value, marking inherited jobs as `(global default)`. +- The formerly hard-coded 30s guard in `runner.RunJob` is now the configurable + default. `StartOnly` fire-and-forget jobs remain unaffected by the run timeout, + continuing to measure launch latency only. + ## 0.11.5 - 2026-07-01 **Quality and documentation polish:** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e04de4a..c900aa5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -5,18 +5,6 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here. ## Open Items -### Per-job command timeout - -`runner.RunJob` applies a fixed **30s** timeout to every command (`commandTimeout` -in `src/runner/runner.go`). Long-running or interactive scripts need a longer -limit; quick health checks may need a shorter one. - -Add an optional per-job timeout (seconds) on `domain.Job`, with a global default -in `gosentry.json` for jobs that leave the field empty — the same inherit pattern -as `overlap_policy`. Wire the value through `RunJob`; expose it in the job dialog -and Settings; validate on save. `StartOnly` jobs should keep measuring launch -latency only and remain unaffected by the run timeout. - ### Window size persistence *(frozen)* Window size is currently **not** saved on quit or close. Saving was disabled diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index ddcc27c..6b6b8ef 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -17,12 +17,13 @@ in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md). - `RunNow` is allowed during global pause and for disabled jobs. - Sequential mode runs jobs FIFO by order in `jobs.json`. - Scheduler tick is 1s — sub-second `@every` intervals are not supported. -- Command timeout is 30s globally. +- Command timeout defaults to 30s globally and is overridable per job + (`Job.TimeoutSeconds`, 0 = inherit `Config.DefaultTimeoutSeconds`). - **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the current process. Log files on disk feed aggregate statistics via `SeedStats` only. See [ARCHITECTURE.md](ARCHITECTURE.md). ## Out of scope -Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (per-job timeout, -window size persistence, History column filters, CI coverage gate). +Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (window size +persistence, History column filters, CI coverage gate). diff --git a/src/app/format.go b/src/app/format.go index 103d677..ef40c26 100644 --- a/src/app/format.go +++ b/src/app/format.go @@ -102,6 +102,17 @@ func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) str return string(globalPolicy) + " (global default)" } +// DisplayTimeout formats a job's effective run timeout for the details panel. +// When the job sets its own TimeoutSeconds it is shown as-is; when 0 (inherit), +// the global default is shown with "(global default)" appended, mirroring +// DisplayOverlapPolicy. +func DisplayTimeout(job domain.Job, globalDefault int) string { + if job.TimeoutSeconds > 0 { + return fmt.Sprintf("%d s", job.TimeoutSeconds) + } + return fmt.Sprintf("%d s (global default)", globalDefault) +} + // DisplayIndex returns the position of jobIndex in the given slice of indexes, // or 0 if not found. func DisplayIndex(indexes []int, jobIndex int) int { diff --git a/src/app/format_test.go b/src/app/format_test.go index f0e6516..c62a585 100644 --- a/src/app/format_test.go +++ b/src/app/format_test.go @@ -163,3 +163,14 @@ func TestDisplayOverlapPolicy(t *testing.T) { t.Errorf("inherited policy = %q, want %q", got, want) } } + +func TestDisplayTimeout(t *testing.T) { + own := domain.Job{TimeoutSeconds: 45} + if got, want := DisplayTimeout(own, 30), "45 s"; got != want { + t.Errorf("per-job timeout = %q, want %q", got, want) + } + inherit := domain.Job{TimeoutSeconds: 0} + if got, want := DisplayTimeout(inherit, 30), "30 s (global default)"; got != want { + t.Errorf("inherited timeout = %q, want %q", got, want) + } +} diff --git a/src/app/operations.go b/src/app/operations.go index 1c559f3..de5ad2a 100644 --- a/src/app/operations.go +++ b/src/app/operations.go @@ -367,6 +367,9 @@ func validateJob(job domain.Job) error { if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) { return errors.New("overlap policy must be 'skip', 'queue', or empty") } + if job.TimeoutSeconds < 0 { + return errors.New("timeout must be zero (inherit) or a positive number of seconds") + } return nil } @@ -390,5 +393,8 @@ func validateConfig(config domain.Config) error { if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue { return errors.New("overlap policy must be 'skip' or 'queue'") } + if config.DefaultTimeoutSeconds <= 0 { + return errors.New("default timeout must be a positive number of seconds") + } return nil } diff --git a/src/app/operations_test.go b/src/app/operations_test.go index 9961867..eedb964 100644 --- a/src/app/operations_test.go +++ b/src/app/operations_test.go @@ -27,7 +27,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service { JobsPath: filepath.Join(dir, "jobs.json"), LogsDir: filepath.Join(dir, "logs"), }, - Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip}, + Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30}, } return NewService(store, jobs) } @@ -103,6 +103,9 @@ func TestCreateJobValidates(t *testing.T) { if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil { t.Error("expected error for invalid overlap policy") } + if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: -1}); err == nil { + t.Error("expected error for negative per-job timeout") + } } func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) { @@ -250,7 +253,7 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) done := make(chan domain.RunRecord, 1) - svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) { if trigger != "Manual" { t.Errorf("trigger = %q, want Manual", trigger) } @@ -298,7 +301,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) { entered := make(chan struct{}, 1) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- struct{}{} <-release @@ -341,7 +344,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) { func TestRunNowAllowedWhilePaused(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) done := make(chan struct{}, 1) - svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { select { case done <- struct{}{}: default: @@ -366,7 +369,7 @@ func TestRunDueStartsDueJob(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) done := make(chan domain.RunRecord, 1) - svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) { if trigger != "Schedule" { t.Errorf("trigger = %q, want Schedule", trigger) } @@ -397,7 +400,7 @@ func TestRunDueStartsDueJob(t *testing.T) { func TestRunDueSkipsJobNotYetDue(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) var ran int32 - svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&ran, 1) return domain.RunRecord{}, nil } @@ -418,7 +421,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) var calls int32 - svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) return domain.RunRecord{State: "Success"}, nil } @@ -442,7 +445,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) { func TestRunDueDoesNothingWhilePaused(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) var ran int32 - svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&ran, 1) return domain.RunRecord{}, nil } @@ -472,7 +475,7 @@ func TestStartDrivesRunDueOnTick(t *testing.T) { svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}) done := make(chan struct{}, 1) - svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { select { case done <- struct{}{}: default: @@ -524,6 +527,7 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) { {"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }}, {"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }}, {"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }}, + {"non-positive default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = 0 }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -596,7 +600,7 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) { var ran int32 runStarted := make(chan struct{}, 1) - svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) { + svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&ran, 1) select { case runStarted <- struct{}{}: diff --git a/src/app/run.go b/src/app/run.go index 958f792..e4cc504 100644 --- a/src/app/run.go +++ b/src/app/run.go @@ -112,6 +112,7 @@ type runEnv struct { logsDir string maxFiles int maxAge int + timeout time.Duration } // startRunLocked transitions a job to "Running", advances its NextDue to the next @@ -142,6 +143,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr logsDir: s.store.Paths.LogsDir, maxFiles: s.store.Config.MaxLogFiles, maxAge: s.store.Config.MaxLogAgeDays, + timeout: s.effectiveTimeout(job), } // Capture ctx under the lock so a concurrent Start/Stop cannot swap it out // from under the goroutine after we release mu. @@ -155,7 +157,7 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr // is not paused, deferred runs are started one at a time until PendingRuns reaches // zero. Each deferred run runs on its own goroutine. func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) { - record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir) + record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout) s.mu.Lock() var cleanupErr, saveErr error @@ -208,6 +210,19 @@ func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy { return s.store.Config.OverlapPolicy } +// effectiveTimeout resolves the run timeout that actually governs a job: the +// job's own TimeoutSeconds when positive, otherwise the global +// Config.DefaultTimeoutSeconds. A non-positive Job.TimeoutSeconds means "inherit +// the global default", which is why normalizeJob leaves 0 rather than +// backfilling the configured value. The caller must hold mu. +func (s *Service) effectiveTimeout(job *domain.Job) time.Duration { + secs := job.TimeoutSeconds + if secs <= 0 { + secs = s.store.Config.DefaultTimeoutSeconds + } + return time.Duration(secs) * time.Second +} + // anyRunningLocked reports whether any loaded job is currently in the "Running" // state. It backs the sequential-mode guards in RunNow and RunDue. The caller // must hold mu. diff --git a/src/app/run_test.go b/src/app/run_test.go index b833bbf..e241c50 100644 --- a/src/app/run_test.go +++ b/src/app/run_test.go @@ -135,7 +135,7 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { entered <- job.ID <-release return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil @@ -176,7 +176,7 @@ func TestRunDueSequentialSerializes(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { entered <- job.ID <-release return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil @@ -213,7 +213,7 @@ func TestRunDueSkipDropsOverlap(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- job.ID <-release @@ -259,7 +259,7 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- job.ID <-release @@ -314,7 +314,7 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) { release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { if atomic.LoadInt32(&calls) == 0 { <-release } @@ -366,7 +366,7 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- job.ID <-release @@ -416,7 +416,7 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- job.ID <-release @@ -464,7 +464,7 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { entered <- job.ID <-release return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil @@ -506,7 +506,7 @@ func TestRunNowSequentialGuard(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { entered <- job.ID <-release return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil @@ -547,7 +547,7 @@ func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) }) var started int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&started, 1) return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil } @@ -573,7 +573,7 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) { entered := make(chan int, 2) release := make(chan struct{}) var calls int32 - svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) { + svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) { atomic.AddInt32(&calls, 1) entered <- job.ID <-release @@ -616,3 +616,21 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) { t.Errorf("runner called %d time(s), want 1", got) } } + +// TestEffectiveTimeout verifies the inherit-or-override resolution: a zero +// Job.TimeoutSeconds falls back to the global default, while a positive value +// overrides it. +func TestEffectiveTimeout(t *testing.T) { + svc := newTempService(t, nil) + svc.store.Config.DefaultTimeoutSeconds = 30 + + inherit := &domain.Job{TimeoutSeconds: 0} + if got, want := svc.effectiveTimeout(inherit), 30*time.Second; got != want { + t.Errorf("inherited timeout = %s, want %s", got, want) + } + + own := &domain.Job{TimeoutSeconds: 5} + if got, want := svc.effectiveTimeout(own), 5*time.Second; got != want { + t.Errorf("per-job timeout = %s, want %s", got, want) + } +} diff --git a/src/app/service.go b/src/app/service.go index 8ebdfa6..ea83e9a 100644 --- a/src/app/service.go +++ b/src/app/service.go @@ -44,7 +44,7 @@ type Service struct { // processes. ctx is the lifecycle context passed to runs; Start replaces it // with a cancelable context so Stop can abort in-flight runs, and until Start // it is context.Background(). - runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) + runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error) ctx context.Context // sched is the timing loop installed by Start; cancel tears down ctx on Stop. diff --git a/src/app/version.go b/src/app/version.go index 8159aeb..77580cc 100644 --- a/src/app/version.go +++ b/src/app/version.go @@ -3,4 +3,4 @@ package app // Version is the application version shown in the GUI and used by build // scripts in artifact names. It is a var rather than a const so release builds // can override it with Go ldflags when CI tags a build. -var Version = "0.11.5" +var Version = "0.12.0" diff --git a/src/domain/config.go b/src/domain/config.go index 0cbfb3d..e13da31 100644 --- a/src/domain/config.go +++ b/src/domain/config.go @@ -40,7 +40,10 @@ type Config struct { NotifyOnFailure bool `json:"notify_on_failure,omitempty"` ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"` - Paused bool `json:"paused,omitempty"` + // DefaultTimeoutSeconds is the run timeout applied to jobs that do not set + // their own Job.TimeoutSeconds. It carries the formerly hard-coded 30s guard. + DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"` + Paused bool `json:"paused,omitempty"` } // JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level diff --git a/src/domain/job.go b/src/domain/job.go index f5f2695..d09a279 100644 --- a/src/domain/job.go +++ b/src/domain/job.go @@ -6,13 +6,17 @@ package domain // separate JobRuntime so the jobs file stays a clean, hand-editable record of // configuration and never mixes in process-lifetime bookkeeping. type Job struct { - ID int `json:"id"` - Name string `json:"name"` - Folder string `json:"folder,omitempty"` - Schedule string `json:"schedule"` - Command string `json:"command"` - Arguments string `json:"arguments,omitempty"` - StartOnly bool `json:"start_only,omitempty"` - Enabled bool `json:"enabled"` - OverlapPolicy string `json:"overlap_policy,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Folder string `json:"folder,omitempty"` + Schedule string `json:"schedule"` + Command string `json:"command"` + Arguments string `json:"arguments,omitempty"` + StartOnly bool `json:"start_only,omitempty"` + Enabled bool `json:"enabled"` + OverlapPolicy string `json:"overlap_policy,omitempty"` + // TimeoutSeconds bounds how long a run may take before it is killed. 0 means + // "inherit the global Config.DefaultTimeoutSeconds", mirroring OverlapPolicy: + // normalizeJobs must leave 0 untouched rather than backfilling the default. + TimeoutSeconds int `json:"timeout_seconds,omitempty"` } diff --git a/src/runner/runner.go b/src/runner/runner.go index 13adb36..fe79641 100644 --- a/src/runner/runner.go +++ b/src/runner/runner.go @@ -12,16 +12,14 @@ import ( "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, error) { +func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (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 - // scheduler; later it can become a per-job setting without changing the - // runner contract. - runCtx, cancel := context.WithTimeout(ctx, commandTimeout) + // stalls. The effective timeout is resolved by the caller (per-job value or + // the global default), keeping the runner ignorant of the global config. + runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var output string @@ -49,7 +47,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string duration := time.Since(started).Round(time.Millisecond) durationMS = duration.Milliseconds() output = formatOutput(stdoutBuf.String(), stderrBuf.String()) - state, detail = runStateDetail(err, runCtx.Err(), duration) + state, detail = runStateDetail(err, runCtx.Err(), duration, timeout) } now := time.Now() @@ -106,12 +104,12 @@ func startOnlyOutput(job domain.Job, pid int) string { return builder.String() } -func runStateDetail(err error, runErr error, duration time.Duration) (string, string) { +func runStateDetail(err error, runErr error, duration time.Duration, timeout time.Duration) (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) + return "Failed", fmt.Sprintf("Timed out after %s", timeout) } 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) diff --git a/src/runner/runner_test.go b/src/runner/runner_test.go index 4cf1bae..e972fdc 100644 --- a/src/runner/runner_test.go +++ b/src/runner/runner_test.go @@ -27,7 +27,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) { Command: echoCommand("header test output"), } - record, err := RunJob(context.Background(), &job, "Schedule", logsDir) + record, err := RunJob(context.Background(), &job, "Schedule", logsDir, 30*time.Second) if err != nil { t.Fatal(err) } @@ -77,7 +77,7 @@ func TestRunJobRecordFields(t *testing.T) { Command: echoCommand("record field check"), } - record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir()) + record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir(), 30*time.Second) if err != nil { t.Fatal(err) } @@ -164,7 +164,7 @@ func TestRunJobWritesLogFile(t *testing.T) { Command: echoCommand("hello from test"), } - record, err := RunJob(context.Background(), &job, "Manual", logsDir) + record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second) if err != nil { t.Fatal(err) } @@ -202,7 +202,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) { Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, } - record, err := RunJob(context.Background(), &job, "Manual", logsDir) + record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second) if err != nil { t.Fatal(err) } @@ -234,7 +234,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) { Command: scriptPath, } - record, err := RunJob(context.Background(), &job, "Manual", logsDir) + record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second) if err != nil { t.Fatal(err) } @@ -259,7 +259,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) { Arguments: "/C\necho separate arguments ok", } - record, err := RunJob(context.Background(), &job, "Manual", logsDir) + record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second) if err != nil { t.Fatal(err) } @@ -285,7 +285,7 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) { job.Arguments = "/C\nexit /b 1" } - record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) + record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second) if err != nil { t.Fatal(err) } @@ -312,7 +312,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) { StartOnly: true, } - record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) + record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second) if err != nil { t.Fatal(err) } @@ -336,7 +336,7 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { StartOnly: true, } - record, err := RunJob(context.Background(), &job, "Manual", t.TempDir()) + record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 30*time.Second) if err != nil { t.Fatal(err) } @@ -348,3 +348,59 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { } } +func TestRunJobTimesOut(t *testing.T) { + command := "sh" + arguments := "-c\nsleep 5" + if runtime.GOOS == "windows" { + command = `C:\Windows\System32\cmd.exe` + // timeout waits ~5s; ping to localhost is a portable stall on hosts where + // timeout refuses to run without an interactive console. + arguments = "/C\nping -n 6 127.0.0.1 >NUL" + } + job := domain.Job{ + ID: 50, + Name: "Timeout Test", + Command: command, + Arguments: arguments, + } + + record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), 100*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if record.State != "Failed" { + t.Fatalf("expected timed-out job to fail, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Detail, "Timed out after 100ms") { + t.Fatalf("expected timeout detail with the effective timeout, got %q", record.Detail) + } +} + +func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) { + command := "sh" + arguments := "-c\nsleep 5" + if runtime.GOOS == "windows" { + command = `C:\Windows\System32\cmd.exe` + arguments = "/C\nping -n 6 127.0.0.1 >NUL" + } + job := domain.Job{ + ID: 51, + Name: "Start Only Timeout", + Command: command, + Arguments: arguments, + StartOnly: true, + } + + // A tiny run timeout must not affect StartOnly jobs: they never wait on the + // timed run context, so the launch succeeds regardless. + record, err := RunJob(context.Background(), &job, "Manual", t.TempDir(), time.Millisecond) + if err != nil { + t.Fatal(err) + } + if record.State != "OK" { + t.Fatalf("expected start-only job to be OK despite tiny timeout, got state %q detail %q", record.State, record.Detail) + } + if !strings.Contains(record.Detail, "not waiting for process exit") { + t.Fatalf("expected start-only detail, got %q", record.Detail) + } +} diff --git a/src/storage/store.go b/src/storage/store.go index edbaef0..4878c1d 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -78,6 +78,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) { NotifyOnFailure: true, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, + + DefaultTimeoutSeconds: 30, } if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { @@ -112,6 +114,9 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) { if config.OverlapPolicy == "" { config.OverlapPolicy = domain.OverlapPolicySkip } + if config.DefaultTimeoutSeconds <= 0 { + config.DefaultTimeoutSeconds = 30 + } return config, nil } diff --git a/src/storage/store_test.go b/src/storage/store_test.go index 583aea7..a75db6d 100644 --- a/src/storage/store_test.go +++ b/src/storage/store_test.go @@ -168,6 +168,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) { if got.MaxLogAgeDays != 30 { t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays) } + if got.DefaultTimeoutSeconds != 30 { + t.Errorf("default DefaultTimeoutSeconds = %d, want 30", got.DefaultTimeoutSeconds) + } // The function must have written the defaults to gosentry.json. if _, err := os.Stat(paths.ConfigPath); err != nil { t.Errorf("gosentry.json should have been created: %v", err) diff --git a/src/ui/job_dialog.go b/src/ui/job_dialog.go index 90e6391..59728e2 100644 --- a/src/ui/job_dialog.go +++ b/src/ui/job_dialog.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "strconv" "strings" "gitea.mixdep.ru/mix/gosentry/src/domain" @@ -53,6 +54,11 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { overlapSelected = current.OverlapPolicy } overlapSelect.SetSelected(overlapSelected) + timeoutEntry := widget.NewEntry() + timeoutEntry.SetPlaceHolder("Empty = use global default") + if current.TimeoutSeconds > 0 { + timeoutEntry.SetText(strconv.Itoa(current.TimeoutSeconds)) + } form := dialog.NewForm( title, @@ -66,6 +72,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { widget.NewFormItem("Arguments", argumentsEntry), widget.NewFormItem("", startOnly), widget.NewFormItem("Overlap policy", overlapSelect), + widget.NewFormItem("Timeout (s)", timeoutEntry), widget.NewFormItem("", enabled), }, func(saved bool) { @@ -82,6 +89,17 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w) return } + // An empty timeout inherits the global default (0); any entry must be a + // positive whole number of seconds. + timeoutSeconds := 0 + if trimmed := strings.TrimSpace(timeoutEntry.Text); trimmed != "" { + parsed, err := strconv.Atoi(trimmed) + if err != nil || parsed <= 0 { + dialog.ShowError(fmt.Errorf("timeout must be a positive number of seconds, or empty to use the global default"), w) + return + } + timeoutSeconds = parsed + } current.Name = strings.TrimSpace(name.Text) current.Folder = strings.TrimSpace(folderEntry.Text) current.Schedule = strings.TrimSpace(scheduleEntry.Text) @@ -93,6 +111,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { if current.OverlapPolicy == overlapPolicyInherit { current.OverlapPolicy = "" } + current.TimeoutSeconds = timeoutSeconds // The dialog only edits durable configuration. Runtime status is // initialized (new jobs) or updated (edits) by the caller against the // runtime map, keyed by job ID. @@ -100,6 +119,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { }, w, ) - form.Resize(fyne.NewSize(640, 460)) + form.Resize(fyne.NewSize(640, 500)) form.Show() } diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index 65eae16..7c689d8 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -72,9 +72,9 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { schedulerPaused := svc.Store().Config.Paused filteredJobs := filteredJobIndexes(jobs, selectedFolder) - dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy) + dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds) if selected >= 0 { - dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) + dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds) } else { dp.clear() } @@ -87,7 +87,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { return } selected = index - dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) + dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds) } // list and folderSelect are declared early so closures below can reference diff --git a/src/ui/jobs_view_details.go b/src/ui/jobs_view_details.go index 51f707f..9373eac 100644 --- a/src/ui/jobs_view_details.go +++ b/src/ui/jobs_view_details.go @@ -22,6 +22,7 @@ type detailsPanel struct { arguments *widget.Label runMode *widget.Label overlapPolicy *widget.Label + timeout *widget.Label lastRun *widget.Label nextRun *widget.Label state *widget.Label @@ -34,7 +35,7 @@ type detailsPanel struct { selectedLogs []event } -func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) *detailsPanel { +func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) *detailsPanel { d := &detailsPanel{ title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folder: newJobDetailLabel(""), @@ -43,6 +44,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do arguments: newJobDetailLabel(""), runMode: newJobDetailLabel(""), overlapPolicy: newJobDetailLabel(""), + timeout: newJobDetailLabel(""), lastRun: newJobDetailLabel(""), nextRun: newJobDetailLabel(""), state: newJobDetailLabel(""), @@ -69,11 +71,11 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id])) }, ) - d.update(firstJob, rt, globalOverlapPolicy) + d.update(firstJob, rt, globalOverlapPolicy, globalTimeout) return d } -func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) { +func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy, globalTimeout int) { d.title.SetText(j.Name) d.folder.SetText(app.DisplayFolder(j.Folder)) d.schedule.SetText(j.Schedule) @@ -81,6 +83,7 @@ func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy d.arguments.SetText(app.DisplayArguments(j.Arguments)) d.runMode.SetText(app.DisplayRunMode(j)) d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy)) + d.timeout.SetText(app.DisplayTimeout(j, globalTimeout)) d.lastRun.SetText(rt.LastRun) d.nextRun.SetText(rt.NextRun) d.state.SetText(rt.LastState) @@ -101,6 +104,7 @@ func (d *detailsPanel) clear() { d.arguments.SetText("") d.runMode.SetText("") d.overlapPolicy.SetText("") + d.timeout.SetText("") d.lastRun.SetText("") d.nextRun.SetText("") d.state.SetText("") @@ -121,8 +125,9 @@ func (d *detailsPanel) container() fyne.CanvasObject { detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule), detailRowPair(capW, "Command", d.command, "Arguments", d.arguments), detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy), + detailRowPair(capW, "Timeout", d.timeout, "State", d.state), detailRowPair(capW, "Last run", d.lastRun, "Next run", d.nextRun), - detailRowPair(capW, "State", d.state, "Statistics", d.stats), + detailRow(capW, "Statistics", d.stats), ) top := container.NewVBox( d.title, @@ -160,7 +165,7 @@ func activityRowsHeight(rows int) float32 { func detailCaptionWidth() float32 { captions := []string{ "Folder", "Schedule", "Command", "Arguments", "Run mode", - "Overlap policy", "Last run", "Next run", "State", "Statistics", + "Overlap policy", "Timeout", "Last run", "Next run", "State", "Statistics", } var width float32 for _, c := range captions { diff --git a/src/ui/mainwindow_test.go b/src/ui/mainwindow_test.go index 9d0241d..c6b6ccc 100644 --- a/src/ui/mainwindow_test.go +++ b/src/ui/mainwindow_test.go @@ -24,14 +24,15 @@ func newTestService(t *testing.T) *app.Service { LogsDir: filepath.Join(dir, "logs"), }, Config: domain.Config{ - JobsDir: ".", - LogsDir: "logs", - MaxLogFiles: 100, - MaxLogAgeDays: 30, - ExecutionMode: domain.ExecutionModeParallel, - OverlapPolicy: domain.OverlapPolicySkip, - KeepRunningInTray: true, - NotifyOnFailure: true, + JobsDir: ".", + LogsDir: "logs", + MaxLogFiles: 100, + MaxLogAgeDays: 30, + ExecutionMode: domain.ExecutionModeParallel, + OverlapPolicy: domain.OverlapPolicySkip, + DefaultTimeoutSeconds: 30, + KeepRunningInTray: true, + NotifyOnFailure: true, }, } return app.NewService(store, nil) diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 4ba94dc..1edc80b 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -73,6 +73,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { ) overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy)) overlapPolicySelect.OnChanged = func(string) { updateSaveState() } + defaultTimeout := widget.NewEntry() + defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds)) + defaultTimeout.OnChanged = func(string) { updateSaveState() } jobsDir := widget.NewEntry() jobsDir.SetText(store.Config.JobsDir) jobsDir.OnChanged = func(string) { updateSaveState() } @@ -116,6 +119,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { settingsStatus.SetText("Logs directory is required") return } + timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text)) + if err != nil || timeout <= 0 { + settingsStatus.SetText("Default timeout must be a positive number") + return + } // Build the new config from the form and hand it to the Service, which // validates it, persists config and jobs to the (possibly new) directory, // and runs log cleanup so tightened retention limits take effect at once. @@ -129,6 +137,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { config.NotifyOnFailure = notifications.Checked config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected) config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected) + config.DefaultTimeoutSeconds = timeout if err := svc.UpdateSettings(config); err != nil { settingsStatus.SetText("Save failed: " + err.Error()) return @@ -155,6 +164,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { notifications.Checked != c.NotifyOnFailure || executionModeSelect.Selected != string(c.ExecutionMode) || overlapPolicySelect.Selected != string(c.OverlapPolicy) || + strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) || strings.TrimSpace(jobsDir.Text) != c.JobsDir || strings.TrimSpace(logsDir.Text) != c.LogsDir || strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) || @@ -188,6 +198,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), + settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)), ), ) rightColumn := container.NewVBox( @@ -293,4 +304,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) return container.NewBorder(nil, nil, captionBox, nil, value) } -