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 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-25 23:24:50 +03:00
parent f4221f6ce4
commit 48faddb3bd
23 changed files with 270 additions and 87 deletions
+20 -1
View File
@@ -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()
}
+3 -3
View File
@@ -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
+10 -5
View File
@@ -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 {
+9 -8
View File
@@ -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)
+11 -1
View File
@@ -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)
}