Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab5f07c7c | |||
| 48faddb3bd |
+16
-2
@@ -87,8 +87,9 @@ flowchart LR
|
|||||||
|
|
||||||
5. Command execution:
|
5. Command execution:
|
||||||
`runner.RunJob` builds the platform-specific invocation, executes the
|
`runner.RunJob` builds the platform-specific invocation, executes the
|
||||||
command through the platform shell, captures stdout and stderr, writes one
|
command through the platform shell under the caller-supplied timeout, captures
|
||||||
timestamped `.log` file, and returns a `domain.RunRecord` containing
|
stdout and stderr, writes one timestamped `.log` file, and returns a
|
||||||
|
`domain.RunRecord` containing
|
||||||
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
|
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
|
||||||
fire-and-forget jobs it measures launch latency — the time to spawn the
|
fire-and-forget jobs it measures launch latency — the time to spawn the
|
||||||
process — since there is no exit to wait for).
|
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
|
`executeRun` drains the counter by starting one deferred run per completion until
|
||||||
`PendingRuns` reaches zero.
|
`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 <timeout>` on
|
||||||
|
expiry. `StartOnly` jobs run on the untimed context and so measure launch latency
|
||||||
|
only, unaffected by the run timeout.
|
||||||
|
|
||||||
### Run-time statistics
|
### Run-time statistics
|
||||||
|
|
||||||
`domain.JobRuntime` holds a rolling aggregate updated after each run:
|
`domain.JobRuntime` holds a rolling aggregate updated after each run:
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
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
|
## 0.11.5 - 2026-07-01
|
||||||
|
|
||||||
**Quality and documentation polish:**
|
**Quality and documentation polish:**
|
||||||
|
|||||||
@@ -5,18 +5,6 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
|||||||
|
|
||||||
## Open Items
|
## 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 persistence *(frozen)*
|
||||||
|
|
||||||
Window size is currently **not** saved on quit or close. Saving was disabled
|
Window size is currently **not** saved on quit or close. Saving was disabled
|
||||||
|
|||||||
+4
-3
@@ -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.
|
- `RunNow` is allowed during global pause and for disabled jobs.
|
||||||
- Sequential mode runs jobs FIFO by order in `jobs.json`.
|
- Sequential mode runs jobs FIFO by order in `jobs.json`.
|
||||||
- Scheduler tick is 1s — sub-second `@every` intervals are not supported.
|
- 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
|
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (per-job timeout,
|
Larger or blocked work is tracked in [ROADMAP.md](ROADMAP.md) (window size
|
||||||
window size persistence, History column filters, CI coverage gate).
|
persistence, History column filters, CI coverage gate).
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
# План реализации: Per-job command timeout
|
|
||||||
|
|
||||||
Реализация пункта **«Per-job command timeout»** из [ROADMAP.md](../ROADMAP.md),
|
|
||||||
построенная по образцу уже реализованного `overlap_policy` — тот же паттерн
|
|
||||||
наследования «пусто → глобальный дефолт».
|
|
||||||
|
|
||||||
## Рекомендуемая модель
|
|
||||||
|
|
||||||
**Claude Opus** (Opus 4.8 или новее).
|
|
||||||
|
|
||||||
Обоснование: это не локальная правка, а сквозное изменение через слои
|
|
||||||
(`domain → storage → runner → app seam → ui → tests`) со сменой сигнатуры
|
|
||||||
`RunJob` и seam-типа `Service.runJob`, что затрагивает ~9 существующих тестов и
|
|
||||||
несколько UI-файлов. Нужна аккуратность в резолве эффективного значения под
|
|
||||||
`mu` и в сохранении контракта «раннер не знает о глобальном конфиге». Для такой
|
|
||||||
кросс-слойной работы с тестами уместен Opus; Sonnet справится с отдельными
|
|
||||||
шагами, но выше риск упустить обновление одного из вызовов/тестов.
|
|
||||||
|
|
||||||
Замечание по окружению: сборка и тесты GUI требуют **CGO + MSYS2 UCRT64**
|
|
||||||
(дефолтный Bash-env идёт с `cgo off`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Модель данных
|
|
||||||
|
|
||||||
**[src/domain/job.go](../../src/domain/job.go)** — добавить поле в `Job`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 0 = наследовать глобальный дефолт
|
|
||||||
```
|
|
||||||
|
|
||||||
**[src/domain/config.go](../../src/domain/config.go)** — добавить в `Config`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
DefaultTimeoutSeconds int `json:"default_timeout_seconds,omitempty"`
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключевое соглашение (как у `OverlapPolicy`): `0` на `Job` означает
|
|
||||||
«наследовать», поэтому `normalizeJob` не должен затирать 0 глобальным значением.
|
|
||||||
|
|
||||||
## Дефолты и загрузка
|
|
||||||
|
|
||||||
**[src/storage/store.go](../../src/storage/store.go)** — по образцу `OverlapPolicy`:
|
|
||||||
|
|
||||||
- в литерал дефолтного `Config` добавить `DefaultTimeoutSeconds: 30`
|
|
||||||
(сохраняет текущее поведение — 30 с);
|
|
||||||
- в блоке нормализации после загрузки:
|
|
||||||
`if config.DefaultTimeoutSeconds <= 0 { config.DefaultTimeoutSeconds = 30 }`.
|
|
||||||
|
|
||||||
Это переносит нынешнюю константу `commandTimeout = 30s` из `runner.go` в конфиг
|
|
||||||
как значение по умолчанию.
|
|
||||||
|
|
||||||
## Runner (сохранить чистоту контракта)
|
|
||||||
|
|
||||||
Раннер не должен знать о глобальном конфиге — эффективный таймаут резолвится в
|
|
||||||
app-слое и передаётся внутрь.
|
|
||||||
|
|
||||||
**[src/runner/runner.go](../../src/runner/runner.go)**:
|
|
||||||
|
|
||||||
- сигнатура `RunJob(ctx, job, trigger, logsDir)` →
|
|
||||||
`RunJob(ctx, job, trigger, logsDir, timeout time.Duration)`;
|
|
||||||
- `runCtx, cancel := context.WithTimeout(ctx, timeout)` вместо константы;
|
|
||||||
- `runStateDetail(...)` принимает `timeout` и печатает его в сообщении
|
|
||||||
`"Timed out after %s"` вместо `commandTimeout`;
|
|
||||||
- **StartOnly не трогаем**: эта ветка использует `jobInvocation(ctx, …)`
|
|
||||||
(не `runCtx`), поэтому run-таймаут её не касается — измерение launch latency
|
|
||||||
сохраняется. Константу `commandTimeout` можно удалить, `commandWaitDelay`
|
|
||||||
оставить.
|
|
||||||
|
|
||||||
## App-слой: резолв и проброс
|
|
||||||
|
|
||||||
**[src/app/run.go](../../src/app/run.go)** — добавить рядом с
|
|
||||||
`effectiveOverlapPolicy`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Резолвить под `mu` в `startRunLocked` и класть в снапшот `runEnv` (там же, где
|
|
||||||
`logsDir`/`maxFiles`) — новое поле `timeout time.Duration`. В `executeRun`
|
|
||||||
передавать `env.timeout` в `s.runJob(...)`.
|
|
||||||
|
|
||||||
**[src/app/service.go](../../src/app/service.go)** — обновить тип seam-поля
|
|
||||||
`runJob` (добавить `timeout time.Duration`); присваивание
|
|
||||||
`runJob: runner.RunJob` останется валидным после смены сигнатуры.
|
|
||||||
|
|
||||||
## Валидация
|
|
||||||
|
|
||||||
**[src/app/operations.go](../../src/app/operations.go)**:
|
|
||||||
|
|
||||||
- `validateJob`:
|
|
||||||
`if job.TimeoutSeconds < 0 { return errors.New("timeout must be zero (inherit) or a positive number of seconds") }`;
|
|
||||||
- `validateConfig`:
|
|
||||||
`if config.DefaultTimeoutSeconds <= 0 { return errors.New("default timeout must be a positive number of seconds") }`.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
**Диалог задачи [src/ui/job_dialog.go](../../src/ui/job_dialog.go)** — рядом с
|
|
||||||
«Overlap policy»: числовой `widget.NewEntry` «Timeout (s)» с плейсхолдером-
|
|
||||||
подсказкой про наследование; пусто → `TimeoutSeconds = 0`, иначе `strconv.Atoi`
|
|
||||||
с показом ошибки как у schedule.
|
|
||||||
|
|
||||||
**Настройки [src/ui/settings_view.go](../../src/ui/settings_view.go)** — в секцию
|
|
||||||
«Queue» добавить поле «Default timeout (s)» рядом с «Default overlap policy»:
|
|
||||||
инициализация из `store.Config.DefaultTimeoutSeconds`,
|
|
||||||
`OnChanged → updateSaveState`, запись в `config.DefaultTimeoutSeconds` при
|
|
||||||
сохранении и учёт в dirty-check.
|
|
||||||
|
|
||||||
**Панель деталей [src/ui/jobs_view_details.go](../../src/ui/jobs_view_details.go)**
|
|
||||||
— новая строка «Timeout», отображающая эффективное значение через новый хелпер
|
|
||||||
в [src/app/format.go](../../src/app/format.go):
|
|
||||||
|
|
||||||
```go
|
|
||||||
func DisplayTimeout(job domain.Job, globalDefault int) string // "45 s" или "30 s (global default)"
|
|
||||||
```
|
|
||||||
|
|
||||||
по образцу `DisplayOverlapPolicy`. Прокинуть `globalDefault` в
|
|
||||||
`newDetailsPanel/update` так же, как уже прокинут `globalOverlapPolicy`.
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
- **[src/runner/runner_test.go](../../src/runner/runner_test.go)** — все 9 вызовов
|
|
||||||
`RunJob` получают новый аргумент; добавить кейс: короткий per-job таймаут →
|
|
||||||
`Failed / Timed out after …`; StartOnly с малым таймаутом → не таймаутит.
|
|
||||||
- **[src/app/run_test.go](../../src/app/run_test.go)** — тест `effectiveTimeout`:
|
|
||||||
инхерит при `TimeoutSeconds==0`, собственное значение перекрывает глобальное.
|
|
||||||
- **[src/app/operations_test.go](../../src/app/operations_test.go)** —
|
|
||||||
отрицательный per-job timeout и неположительный default отклоняются.
|
|
||||||
- **[src/app/format_test.go](../../src/app/format_test.go)** — `DisplayTimeout`
|
|
||||||
(собственное значение vs «(global default)»).
|
|
||||||
- **settings / mainwindow тесты** — при необходимости обновить конструкторы
|
|
||||||
`Config`.
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
- Удалить раздел из [ROADMAP.md](../ROADMAP.md).
|
|
||||||
- Обновить упоминания сигнатуры/таймаута `RunJob` в
|
|
||||||
[ARCHITECTURE.md](../ARCHITECTURE.md).
|
|
||||||
- Запись в [CHANGELOG.md](../CHANGELOG.md).
|
|
||||||
|
|
||||||
## Порядок работ
|
|
||||||
|
|
||||||
1. `domain` → storage-дефолты (компилируется, поведение прежнее);
|
|
||||||
2. `runner` + seam-сигнатура + прогон таймаута через `runEnv` → чиним
|
|
||||||
компиляцию тестов;
|
|
||||||
3. валидация;
|
|
||||||
4. UI (диалог, настройки, детали);
|
|
||||||
5. тесты + доки.
|
|
||||||
@@ -102,6 +102,17 @@ func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) str
|
|||||||
return string(globalPolicy) + " (global default)"
|
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,
|
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
||||||
// or 0 if not found.
|
// or 0 if not found.
|
||||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||||
|
|||||||
@@ -163,3 +163,14 @@ func TestDisplayOverlapPolicy(t *testing.T) {
|
|||||||
t.Errorf("inherited policy = %q, want %q", got, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ func validateJob(job domain.Job) error {
|
|||||||
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
||||||
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,5 +393,8 @@ func validateConfig(config domain.Config) error {
|
|||||||
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
||||||
return errors.New("overlap policy must be 'skip' or 'queue'")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -27,7 +27,7 @@ func newTempService(t *testing.T, jobs []domain.Job) *Service {
|
|||||||
JobsPath: filepath.Join(dir, "jobs.json"),
|
JobsPath: filepath.Join(dir, "jobs.json"),
|
||||||
LogsDir: filepath.Join(dir, "logs"),
|
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)
|
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 {
|
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")
|
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) {
|
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}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
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" {
|
if trigger != "Manual" {
|
||||||
t.Errorf("trigger = %q, want Manual", trigger)
|
t.Errorf("trigger = %q, want Manual", trigger)
|
||||||
}
|
}
|
||||||
@@ -298,7 +301,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- struct{}{}
|
entered <- struct{}{}
|
||||||
<-release
|
<-release
|
||||||
@@ -341,7 +344,7 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
done := make(chan struct{}, 1)
|
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 {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
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}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
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" {
|
if trigger != "Schedule" {
|
||||||
t.Errorf("trigger = %q, want Schedule", trigger)
|
t.Errorf("trigger = %q, want Schedule", trigger)
|
||||||
}
|
}
|
||||||
@@ -397,7 +400,7 @@ func TestRunDueStartsDueJob(t *testing.T) {
|
|||||||
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
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)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}, nil
|
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}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
return domain.RunRecord{State: "Success"}, nil
|
return domain.RunRecord{State: "Success"}, nil
|
||||||
}
|
}
|
||||||
@@ -442,7 +445,7 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
|||||||
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
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)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}, nil
|
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}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan struct{}, 1)
|
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 {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -524,6 +527,7 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
|||||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
||||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
{"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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -596,7 +600,7 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
|||||||
|
|
||||||
var ran int32
|
var ran int32
|
||||||
runStarted := make(chan struct{}, 1)
|
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)
|
atomic.AddInt32(&ran, 1)
|
||||||
select {
|
select {
|
||||||
case runStarted <- struct{}{}:
|
case runStarted <- struct{}{}:
|
||||||
|
|||||||
+16
-1
@@ -112,6 +112,7 @@ type runEnv struct {
|
|||||||
logsDir string
|
logsDir string
|
||||||
maxFiles int
|
maxFiles int
|
||||||
maxAge int
|
maxAge int
|
||||||
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
// 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,
|
logsDir: s.store.Paths.LogsDir,
|
||||||
maxFiles: s.store.Config.MaxLogFiles,
|
maxFiles: s.store.Config.MaxLogFiles,
|
||||||
maxAge: s.store.Config.MaxLogAgeDays,
|
maxAge: s.store.Config.MaxLogAgeDays,
|
||||||
|
timeout: s.effectiveTimeout(job),
|
||||||
}
|
}
|
||||||
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||||
// from under the goroutine after we release mu.
|
// 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
|
// is not paused, deferred runs are started one at a time until PendingRuns reaches
|
||||||
// zero. Each deferred run runs on its own goroutine.
|
// zero. Each deferred run runs on its own goroutine.
|
||||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
|
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()
|
s.mu.Lock()
|
||||||
var cleanupErr, saveErr error
|
var cleanupErr, saveErr error
|
||||||
@@ -208,6 +210,19 @@ func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
|
|||||||
return s.store.Config.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"
|
// anyRunningLocked reports whether any loaded job is currently in the "Running"
|
||||||
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
|
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
|
||||||
// must hold mu.
|
// must hold mu.
|
||||||
|
|||||||
+29
-11
@@ -135,7 +135,7 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
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
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
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)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
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
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
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)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -259,7 +259,7 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -314,7 +314,7 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
|
|||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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 {
|
if atomic.LoadInt32(&calls) == 0 {
|
||||||
<-release
|
<-release
|
||||||
}
|
}
|
||||||
@@ -366,7 +366,7 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -416,7 +416,7 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -464,7 +464,7 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
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
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
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)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
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
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
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) })
|
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
|
||||||
|
|
||||||
var started int32
|
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)
|
atomic.AddInt32(&started, 1)
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
|
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)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
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)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
@@ -616,3 +616,21 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
|||||||
t.Errorf("runner called %d time(s), want 1", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ type Service struct {
|
|||||||
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
// 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
|
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
||||||
// it is context.Background().
|
// 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
|
ctx context.Context
|
||||||
|
|
||||||
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// 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
|
// 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.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "0.11.5"
|
var Version = "0.12.0"
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ type Config struct {
|
|||||||
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
||||||
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
||||||
OverlapPolicy OverlapPolicy `json:"overlap_policy,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
|
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
||||||
|
|||||||
+13
-9
@@ -6,13 +6,17 @@ package domain
|
|||||||
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
|
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
|
||||||
// configuration and never mixes in process-lifetime bookkeeping.
|
// configuration and never mixes in process-lifetime bookkeeping.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Arguments string `json:"arguments,omitempty"`
|
Arguments string `json:"arguments,omitempty"`
|
||||||
StartOnly bool `json:"start_only,omitempty"`
|
StartOnly bool `json:"start_only,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
OverlapPolicy string `json:"overlap_policy,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,14 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandTimeout = 30 * time.Second
|
|
||||||
const commandWaitDelay = 2 * 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()
|
started := time.Now()
|
||||||
// Commands can hang forever if a script waits for input or a child process
|
// 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
|
// stalls. The effective timeout is resolved by the caller (per-job value or
|
||||||
// scheduler; later it can become a per-job setting without changing the
|
// the global default), keeping the runner ignorant of the global config.
|
||||||
// runner contract.
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var output string
|
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)
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
durationMS = duration.Milliseconds()
|
durationMS = duration.Milliseconds()
|
||||||
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
||||||
state, detail = runStateDetail(err, runCtx.Err(), duration)
|
state, detail = runStateDetail(err, runCtx.Err(), duration, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -106,12 +104,12 @@ func startOnlyOutput(job domain.Job, pid int) string {
|
|||||||
return builder.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 {
|
if err == nil {
|
||||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||||
}
|
}
|
||||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
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) {
|
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)
|
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
|
|||||||
Command: echoCommand("header test output"),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestRunJobRecordFields(t *testing.T) {
|
|||||||
Command: echoCommand("record field check"),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func TestRunJobWritesLogFile(t *testing.T) {
|
|||||||
Command: echoCommand("hello from test"),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
|||||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
|||||||
Command: scriptPath,
|
Command: scriptPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir, 30*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -259,7 +259,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
|||||||
Arguments: "/C\necho separate arguments ok",
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
|
|||||||
job.Arguments = "/C\nexit /b 1"
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -312,7 +312,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
StartOnly: true,
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
|||||||
StartOnly: true,
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|||||||
NotifyOnFailure: true,
|
NotifyOnFailure: true,
|
||||||
ExecutionMode: domain.ExecutionModeParallel,
|
ExecutionMode: domain.ExecutionModeParallel,
|
||||||
OverlapPolicy: domain.OverlapPolicySkip,
|
OverlapPolicy: domain.OverlapPolicySkip,
|
||||||
|
|
||||||
|
DefaultTimeoutSeconds: 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
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 == "" {
|
if config.OverlapPolicy == "" {
|
||||||
config.OverlapPolicy = domain.OverlapPolicySkip
|
config.OverlapPolicy = domain.OverlapPolicySkip
|
||||||
}
|
}
|
||||||
|
if config.DefaultTimeoutSeconds <= 0 {
|
||||||
|
config.DefaultTimeoutSeconds = 30
|
||||||
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
if got.MaxLogAgeDays != 30 {
|
if got.MaxLogAgeDays != 30 {
|
||||||
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
|
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.
|
// The function must have written the defaults to gosentry.json.
|
||||||
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
||||||
t.Errorf("gosentry.json should have been created: %v", err)
|
t.Errorf("gosentry.json should have been created: %v", err)
|
||||||
|
|||||||
+20
-1
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"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
|
overlapSelected = current.OverlapPolicy
|
||||||
}
|
}
|
||||||
overlapSelect.SetSelected(overlapSelected)
|
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(
|
form := dialog.NewForm(
|
||||||
title,
|
title,
|
||||||
@@ -66,6 +72,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
widget.NewFormItem("Arguments", argumentsEntry),
|
widget.NewFormItem("Arguments", argumentsEntry),
|
||||||
widget.NewFormItem("", startOnly),
|
widget.NewFormItem("", startOnly),
|
||||||
widget.NewFormItem("Overlap policy", overlapSelect),
|
widget.NewFormItem("Overlap policy", overlapSelect),
|
||||||
|
widget.NewFormItem("Timeout (s)", timeoutEntry),
|
||||||
widget.NewFormItem("", enabled),
|
widget.NewFormItem("", enabled),
|
||||||
},
|
},
|
||||||
func(saved bool) {
|
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)
|
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
|
||||||
return
|
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.Name = strings.TrimSpace(name.Text)
|
||||||
current.Folder = strings.TrimSpace(folderEntry.Text)
|
current.Folder = strings.TrimSpace(folderEntry.Text)
|
||||||
current.Schedule = strings.TrimSpace(scheduleEntry.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 {
|
if current.OverlapPolicy == overlapPolicyInherit {
|
||||||
current.OverlapPolicy = ""
|
current.OverlapPolicy = ""
|
||||||
}
|
}
|
||||||
|
current.TimeoutSeconds = timeoutSeconds
|
||||||
// The dialog only edits durable configuration. Runtime status is
|
// The dialog only edits durable configuration. Runtime status is
|
||||||
// initialized (new jobs) or updated (edits) by the caller against the
|
// initialized (new jobs) or updated (edits) by the caller against the
|
||||||
// runtime map, keyed by job ID.
|
// runtime map, keyed by job ID.
|
||||||
@@ -100,6 +119,6 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
|||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
)
|
)
|
||||||
form.Resize(fyne.NewSize(640, 460))
|
form.Resize(fyne.NewSize(640, 500))
|
||||||
form.Show()
|
form.Show()
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -72,9 +72,9 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
schedulerPaused := svc.Store().Config.Paused
|
schedulerPaused := svc.Store().Config.Paused
|
||||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
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 {
|
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 {
|
} else {
|
||||||
dp.clear()
|
dp.clear()
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
selected = index
|
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
|
// list and folderSelect are declared early so closures below can reference
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type detailsPanel struct {
|
|||||||
arguments *widget.Label
|
arguments *widget.Label
|
||||||
runMode *widget.Label
|
runMode *widget.Label
|
||||||
overlapPolicy *widget.Label
|
overlapPolicy *widget.Label
|
||||||
|
timeout *widget.Label
|
||||||
lastRun *widget.Label
|
lastRun *widget.Label
|
||||||
nextRun *widget.Label
|
nextRun *widget.Label
|
||||||
state *widget.Label
|
state *widget.Label
|
||||||
@@ -34,7 +35,7 @@ type detailsPanel struct {
|
|||||||
selectedLogs []event
|
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{
|
d := &detailsPanel{
|
||||||
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
folder: newJobDetailLabel(""),
|
folder: newJobDetailLabel(""),
|
||||||
@@ -43,6 +44,7 @@ func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy do
|
|||||||
arguments: newJobDetailLabel(""),
|
arguments: newJobDetailLabel(""),
|
||||||
runMode: newJobDetailLabel(""),
|
runMode: newJobDetailLabel(""),
|
||||||
overlapPolicy: newJobDetailLabel(""),
|
overlapPolicy: newJobDetailLabel(""),
|
||||||
|
timeout: newJobDetailLabel(""),
|
||||||
lastRun: newJobDetailLabel(""),
|
lastRun: newJobDetailLabel(""),
|
||||||
nextRun: newJobDetailLabel(""),
|
nextRun: newJobDetailLabel(""),
|
||||||
state: 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]))
|
item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id]))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
d.update(firstJob, rt, globalOverlapPolicy)
|
d.update(firstJob, rt, globalOverlapPolicy, globalTimeout)
|
||||||
return d
|
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.title.SetText(j.Name)
|
||||||
d.folder.SetText(app.DisplayFolder(j.Folder))
|
d.folder.SetText(app.DisplayFolder(j.Folder))
|
||||||
d.schedule.SetText(j.Schedule)
|
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.arguments.SetText(app.DisplayArguments(j.Arguments))
|
||||||
d.runMode.SetText(app.DisplayRunMode(j))
|
d.runMode.SetText(app.DisplayRunMode(j))
|
||||||
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy))
|
||||||
|
d.timeout.SetText(app.DisplayTimeout(j, globalTimeout))
|
||||||
d.lastRun.SetText(rt.LastRun)
|
d.lastRun.SetText(rt.LastRun)
|
||||||
d.nextRun.SetText(rt.NextRun)
|
d.nextRun.SetText(rt.NextRun)
|
||||||
d.state.SetText(rt.LastState)
|
d.state.SetText(rt.LastState)
|
||||||
@@ -101,6 +104,7 @@ func (d *detailsPanel) clear() {
|
|||||||
d.arguments.SetText("")
|
d.arguments.SetText("")
|
||||||
d.runMode.SetText("")
|
d.runMode.SetText("")
|
||||||
d.overlapPolicy.SetText("")
|
d.overlapPolicy.SetText("")
|
||||||
|
d.timeout.SetText("")
|
||||||
d.lastRun.SetText("")
|
d.lastRun.SetText("")
|
||||||
d.nextRun.SetText("")
|
d.nextRun.SetText("")
|
||||||
d.state.SetText("")
|
d.state.SetText("")
|
||||||
@@ -121,8 +125,9 @@ func (d *detailsPanel) container() fyne.CanvasObject {
|
|||||||
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
detailRowPair(capW, "Folder", d.folder, "Schedule", d.schedule),
|
||||||
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
detailRowPair(capW, "Command", d.command, "Arguments", d.arguments),
|
||||||
detailRowPair(capW, "Run mode", d.runMode, "Overlap policy", d.overlapPolicy),
|
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, "Last run", d.lastRun, "Next run", d.nextRun),
|
||||||
detailRowPair(capW, "State", d.state, "Statistics", d.stats),
|
detailRow(capW, "Statistics", d.stats),
|
||||||
)
|
)
|
||||||
top := container.NewVBox(
|
top := container.NewVBox(
|
||||||
d.title,
|
d.title,
|
||||||
@@ -160,7 +165,7 @@ func activityRowsHeight(rows int) float32 {
|
|||||||
func detailCaptionWidth() float32 {
|
func detailCaptionWidth() float32 {
|
||||||
captions := []string{
|
captions := []string{
|
||||||
"Folder", "Schedule", "Command", "Arguments", "Run mode",
|
"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
|
var width float32
|
||||||
for _, c := range captions {
|
for _, c := range captions {
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ func newTestService(t *testing.T) *app.Service {
|
|||||||
LogsDir: filepath.Join(dir, "logs"),
|
LogsDir: filepath.Join(dir, "logs"),
|
||||||
},
|
},
|
||||||
Config: domain.Config{
|
Config: domain.Config{
|
||||||
JobsDir: ".",
|
JobsDir: ".",
|
||||||
LogsDir: "logs",
|
LogsDir: "logs",
|
||||||
MaxLogFiles: 100,
|
MaxLogFiles: 100,
|
||||||
MaxLogAgeDays: 30,
|
MaxLogAgeDays: 30,
|
||||||
ExecutionMode: domain.ExecutionModeParallel,
|
ExecutionMode: domain.ExecutionModeParallel,
|
||||||
OverlapPolicy: domain.OverlapPolicySkip,
|
OverlapPolicy: domain.OverlapPolicySkip,
|
||||||
KeepRunningInTray: true,
|
DefaultTimeoutSeconds: 30,
|
||||||
NotifyOnFailure: true,
|
KeepRunningInTray: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return app.NewService(store, nil)
|
return app.NewService(store, nil)
|
||||||
|
|||||||
+11
-1
@@ -73,6 +73,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
)
|
)
|
||||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
||||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||||
|
defaultTimeout := widget.NewEntry()
|
||||||
|
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
||||||
|
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||||
jobsDir := widget.NewEntry()
|
jobsDir := widget.NewEntry()
|
||||||
jobsDir.SetText(store.Config.JobsDir)
|
jobsDir.SetText(store.Config.JobsDir)
|
||||||
jobsDir.OnChanged = func(string) { updateSaveState() }
|
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")
|
settingsStatus.SetText("Logs directory is required")
|
||||||
return
|
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
|
// 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,
|
// validates it, persists config and jobs to the (possibly new) directory,
|
||||||
// and runs log cleanup so tightened retention limits take effect at once.
|
// 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.NotifyOnFailure = notifications.Checked
|
||||||
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected)
|
||||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||||
|
config.DefaultTimeoutSeconds = timeout
|
||||||
if err := svc.UpdateSettings(config); err != nil {
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
@@ -155,6 +164,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
notifications.Checked != c.NotifyOnFailure ||
|
notifications.Checked != c.NotifyOnFailure ||
|
||||||
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
executionModeSelect.Selected != string(c.ExecutionMode) ||
|
||||||
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
overlapPolicySelect.Selected != string(c.OverlapPolicy) ||
|
||||||
|
strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) ||
|
||||||
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
strings.TrimSpace(jobsDir.Text) != c.JobsDir ||
|
||||||
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
strings.TrimSpace(logsDir.Text) != c.LogsDir ||
|
||||||
strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) ||
|
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}),
|
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
|
||||||
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
|
||||||
|
settingsRow("Default timeout (s)", container.New(minWidthLayout{width: settingsControlWidth}, defaultTimeout)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rightColumn := container.NewVBox(
|
rightColumn := container.NewVBox(
|
||||||
@@ -293,4 +304,3 @@ func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
|||||||
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
||||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user