fix: harden run persistence and error surfacing from code review

Snapshot store paths under lock before async runs, roll back failed
start/save state, emit UI events only after successful persistence,
surface log write failures, and sync stale YAML docs to JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mixeme
2026-06-29 21:33:50 +03:00
parent 09c5edc993
commit e9fc9eaba0
16 changed files with 230 additions and 88 deletions
+3 -2
View File
@@ -81,8 +81,9 @@ flowchart LR
4. Manual run: 4. Manual run:
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job `Run now` in the UI calls `Service.RunNow`. The Service checks that the job
exists, is not already running, and that the scheduler is not globally paused, exists, is not already running, and (in sequential mode) that no other job is
then executes `runner.RunJob` with the `Manual` trigger. running, then executes `runner.RunJob` with the `Manual` trigger. Manual runs
are allowed even while the scheduler is globally paused.
5. Command execution: 5. Command execution:
`runner.RunJob` builds the platform-specific invocation, executes the `runner.RunJob` builds the platform-specific invocation, executes the
+51
View File
@@ -0,0 +1,51 @@
# GoSentry — Code Review (2026-06-29)
Версия на момент ревью: **0.11.2**
## Итог
| Критерий | Оценка |
|----------|--------|
| Архитектура | 9/10 |
| Сложность vs масштаб | 8/10 |
| Качество кода | 8/10 |
| Поддерживаемость | 8/10 |
| Логические ошибки | 8/10 (после исправлений) |
Проект зрелый и поддерживаемый для десктопного планировщика (~59 `.go`-файлов). Архитектура слоистая, core-логика хорошо протестирована.
## Сильные стороны
- Single-writer `app.Service` с явным locking contract
- Разделение `domain.Job` (durable) и `domain.JobRuntime` (transient)
- Event-driven UI без обратных вызовов в Fyne под lock
- Portable storage от `os.Executable()`
- Инъекция `runJob` и `scheduler.Clock` в тестах
- Подробная документация (`ARCHITECTURE.md`, inline comments)
## Найденные проблемы и статус исправлений
| # | Проблема | Серьёзность | Статус |
|---|----------|-------------|--------|
| 1 | Data race: `store.Paths` в `executeRun` без lock | Высокая | Исправлено |
| 2 | Run стартует при ошибке `SaveJobs` | Средняя | Исправлено |
| 3 | CRUD эмитит events при failed save | Средняя | Исправлено |
| 4 | Overlap queue — только один `Pending` | Средняя | Документировано (by design) |
| 5 | `time.Now()` vs scheduler clock в `startRunLocked` | Низкая | Исправлено |
| 6 | Silent log write failures | Низкая | Исправлено |
| 7 | Невалидный per-job `overlap_policy` | Низкая | Исправлено |
| 8 | Docs drift (YAML, RunNow/pause) | Низкая | Исправлено |
## Намеренное поведение (не баги)
- `RunNow` разрешён при global pause и для disabled jobs
- Sequential mode — FIFO по порядку в `jobs.json`
- Scheduler tick 1s — sub-second `@every` не поддерживается
- Command timeout 30s — глобальный лимит
## Рекомендации на будущее
- UI widget tests или smoke E2E
- Per-job command timeout в конфиге
- Счётчик вместо `Pending bool` для overlap queue (если нужна полная очередь)
- Убрать legacy ticket-ссылки (T3.1) из комментариев
+1 -3
View File
@@ -170,15 +170,13 @@ Tests display-formatting helpers used by the UI.
**Package:** `storage` **Package:** `storage`
Tests JSON round-tripping, YAML migration import, and default generation. Tests JSON round-tripping and default generation.
| Test | Purpose | | Test | Purpose |
|------|---------| |------|---------|
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. | | `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. | | `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. | | `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
| `TestLoadOrCreateConfigMigratesFromLegacy` | Verifies that when `gosentry.json` is absent but `gosentry.yaml` exists the config is imported from the legacy YAML file on first load. |
| `TestLoadOrCreateJobsMigratesFromLegacy` | Verifies that when `jobs.json` is absent but `jobs.yaml` exists the jobs are imported from the legacy YAML file on first load. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. | | `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. | | `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
+33 -10
View File
@@ -42,11 +42,17 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
record := uiRecord(job.ID, job.Name, "Created", "Job was added") record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record) prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs) err := s.store.SaveJobs(s.jobs)
if err != nil {
s.jobs = s.jobs[:len(s.jobs)-1]
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
s.mu.Unlock() s.mu.Unlock()
s.emit(RunRecorded{Record: record}) s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID}) s.emit(JobChanged{JobID: job.ID})
return job, err return job, nil
} }
// UpdateJob replaces the durable configuration of the job with the same ID, // UpdateJob replaces the durable configuration of the job with the same ID,
@@ -82,9 +88,12 @@ func (s *Service) UpdateJob(job domain.Job) error {
err := s.store.SaveJobs(s.jobs) err := s.store.SaveJobs(s.jobs)
s.mu.Unlock() s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record}) s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID}) s.emit(JobChanged{JobID: job.ID})
return err return nil
} }
// DeleteJob removes the job with the given ID along with its runtime and cached // DeleteJob removes the job with the given ID along with its runtime and cached
@@ -105,9 +114,12 @@ func (s *Service) DeleteJob(id int) error {
err := s.store.SaveJobs(s.jobs) err := s.store.SaveJobs(s.jobs)
s.mu.Unlock() s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record}) s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: 0}) s.emit(JobChanged{JobID: 0})
return err return nil
} }
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready" // SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
@@ -140,15 +152,19 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
err := s.store.SaveJobs(s.jobs) err := s.store.SaveJobs(s.jobs)
s.mu.Unlock() s.mu.Unlock()
if err != nil {
return err
}
s.emit(RunRecorded{Record: record}) s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: id}) s.emit(JobChanged{JobID: id})
return err return nil
} }
// SetGlobalPause flips the global pause that gates all execution, scheduled and // SetGlobalPause flips the global pause that gates scheduled execution.
// manual. Each enabled job's next-run text reflects the new state immediately so // Manual "Run now" remains available while paused. Each enabled job's next-run
// the list view is understandable before the next tick. A "Paused"/"Resumed" // text reflects the new state immediately so the list view is understandable
// scheduler activity record and a SchedulerStateChanged event are emitted. // before the next tick. A "Paused"/"Resumed" scheduler activity record and a
// SchedulerStateChanged event are emitted.
func (s *Service) SetGlobalPause(paused bool) error { func (s *Service) SetGlobalPause(paused bool) error {
s.mu.Lock() s.mu.Lock()
s.paused = paused s.paused = paused
@@ -165,13 +181,16 @@ func (s *Service) SetGlobalPause(paused bool) error {
} }
s.mu.Unlock() s.mu.Unlock()
if err != nil {
return err
}
state, detail := "Resumed", "All job execution resumed" state, detail := "Resumed", "All job execution resumed"
if paused { if paused {
state, detail = "Paused", "All job execution paused" state, detail = "Paused", "All job execution paused"
} }
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)}) s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
s.emit(SchedulerStateChanged{Paused: paused}) s.emit(SchedulerStateChanged{Paused: paused})
return err return nil
} }
// ShouldNotifyOnFailure reports whether the user has enabled desktop // ShouldNotifyOnFailure reports whether the user has enabled desktop
@@ -344,6 +363,10 @@ func validateJob(job domain.Job) error {
if job.Name == "" || job.Schedule == "" || job.Command == "" { if job.Name == "" || job.Schedule == "" || job.Command == "" {
return errors.New("name, schedule, and command are required") return errors.New("name, schedule, and command are required")
} }
policy := strings.TrimSpace(job.OverlapPolicy)
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
return errors.New("overlap policy must be 'skip', 'queue', or empty")
}
return nil return nil
} }
+21 -18
View File
@@ -100,6 +100,9 @@ func TestCreateJobValidates(t *testing.T) {
if got := svc.Jobs(); len(got) != 0 { if got := svc.Jobs(); len(got) != 0 {
t.Errorf("invalid job should not be stored, jobs = %+v", got) t.Errorf("invalid job should not be stored, jobs = %+v", got)
} }
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")
}
} }
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) { func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
@@ -247,11 +250,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
if trigger != "Manual" { if trigger != "Manual" {
t.Errorf("trigger = %q, want Manual", trigger) t.Errorf("trigger = %q, want Manual", trigger)
} }
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"} return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
} }
svc.Subscribe(ObserverFunc(func(e Event) { svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 { if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
@@ -295,11 +298,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- struct{}{} entered <- struct{}{}
<-release <-release
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := make(chan struct{}, 1) done := make(chan struct{}, 1)
svc.Subscribe(ObserverFunc(func(e Event) { svc.Subscribe(ObserverFunc(func(e Event) {
@@ -338,12 +341,12 @@ 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 { svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
select { select {
case done <- struct{}{}: case done <- struct{}{}:
default: default:
} }
return domain.RunRecord{State: "Success"} return domain.RunRecord{State: "Success"}, nil
} }
if err := svc.SetGlobalPause(true); err != nil { if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err) t.Fatalf("SetGlobalPause: %v", err)
@@ -363,11 +366,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
if trigger != "Schedule" { if trigger != "Schedule" {
t.Errorf("trigger = %q, want Schedule", trigger) t.Errorf("trigger = %q, want Schedule", trigger)
} }
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"} return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
} }
svc.Subscribe(ObserverFunc(func(e Event) { svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" { if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
@@ -394,9 +397,9 @@ 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 { svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
return domain.RunRecord{} return domain.RunRecord{}, nil
} }
// Next-due is ~1m out, so nothing is due "now". // Next-due is ~1m out, so nothing is due "now".
@@ -415,9 +418,9 @@ 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 { svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"} return domain.RunRecord{State: "Success"}, nil
} }
// Force the job into "Running" with a past NextDue, simulating an in-flight // Force the job into "Running" with a past NextDue, simulating an in-flight
@@ -439,9 +442,9 @@ 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 { svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
return domain.RunRecord{} return domain.RunRecord{}, nil
} }
if err := svc.SetGlobalPause(true); err != nil { if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err) t.Fatalf("SetGlobalPause: %v", err)
@@ -469,12 +472,12 @@ 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 { svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
select { select {
case done <- struct{}{}: case done <- struct{}{}:
default: default:
} }
return domain.RunRecord{State: "Success"} return domain.RunRecord{State: "Success"}, nil
} }
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)} clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
@@ -593,13 +596,13 @@ 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 { svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1) atomic.AddInt32(&ran, 1)
select { select {
case runStarted <- struct{}{}: case runStarted <- struct{}{}:
default: default:
} }
return domain.RunRecord{} return domain.RunRecord{}, nil
} }
// RunDue must not start any job while paused: the scheduler stays paused after // RunDue must not start any job while paused: the scheduler stays paused after
+2
View File
@@ -9,7 +9,9 @@ import (
// store.Paths.DesktopIcon so ApplyAutostart can reference it. // store.Paths.DesktopIcon so ApplyAutostart can reference it.
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) { func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil { if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
s.mu.Lock()
s.store.Paths.DesktopIcon = iconPath s.store.Paths.DesktopIcon = iconPath
s.mu.Unlock()
} }
} }
+47 -12
View File
@@ -36,11 +36,13 @@ func (s *Service) RunNow(id int) error {
s.mu.Unlock() s.mu.Unlock()
return errors.New("another job is already running (sequential mode)") return errors.New("another job is already running (sequential mode)")
} }
err := s.startRunLocked(job, runtime, "Manual") err := s.startRunLocked(job, runtime, "Manual", time.Now())
s.mu.Unlock() s.mu.Unlock()
if err == nil {
// Reflect the "Running" transition; the run's completion emits again later. // Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id}) s.emit(JobChanged{JobID: id})
}
return err return err
} }
@@ -86,8 +88,9 @@ func (s *Service) RunDue(now time.Time) {
// tick once the in-flight run has finished. // tick once the in-flight run has finished.
continue continue
} }
if err := s.startRunLocked(job, runtime, "Schedule"); err != nil { if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
startErr = err startErr = err
continue
} }
started = append(started, job.ID) started = append(started, job.ID)
running = true running = true
@@ -103,22 +106,47 @@ func (s *Service) RunDue(now time.Time) {
} }
} }
// runEnv snapshots path and retention settings for one background run so
// executeRun does not read store.Paths or store.Config without holding mu.
type runEnv struct {
logsDir string
maxFiles int
maxAge int
}
// startRunLocked transitions a job to "Running", advances its NextDue to the next // startRunLocked transitions a job to "Running", advances its NextDue to the next
// scheduled occurrence, persists that, and launches the run on a background // scheduled occurrence, persists that, and launches the run on a background
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching // goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
// while the run is in flight, which is what lets RunDue notice a fresh occurrence // while the run is in flight, which is what lets RunDue notice a fresh occurrence
// firing during a long run and apply the overlap policy. The caller must hold mu. // firing during a long run and apply the overlap policy. The caller must hold mu.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string) error { // now is the reference time for next-due advancement and the running placeholder.
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
jobCopy := *job jobCopy := *job
prevState := runtime.LastState
prevNextRun := runtime.NextRun
prevOutput := runtime.Output
prevNextDue := runtime.NextDue
runtime.LastState = "Running" runtime.LastState = "Running"
runtime.NextRun = "Running" runtime.NextRun = "Running"
runtime.Output = runningOutput(jobCopy, trigger, time.Now()) runtime.Output = runningOutput(jobCopy, trigger, now)
s.advanceNextDueLocked(job, runtime, time.Now()) s.advanceNextDueLocked(job, runtime, now)
err := s.store.SaveJobs(s.jobs) if err := s.store.SaveJobs(s.jobs); err != nil {
runtime.LastState = prevState
runtime.NextRun = prevNextRun
runtime.Output = prevOutput
runtime.NextDue = prevNextDue
return err
}
env := runEnv{
logsDir: s.store.Paths.LogsDir,
maxFiles: s.store.Config.MaxLogFiles,
maxAge: s.store.Config.MaxLogAgeDays,
}
// 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.
go s.executeRun(s.ctx, jobCopy, trigger) go s.executeRun(s.ctx, jobCopy, trigger, env)
return err return nil
} }
// executeRun runs the job off the lock, then records the result back through the // executeRun runs the job off the lock, then records the result back through the
@@ -126,11 +154,12 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
// running (the "queue" overlap policy), and it is still enabled and the scheduler // running (the "queue" overlap policy), and it is still enabled and the scheduler
// is not paused, the deferred run is started immediately. It runs on its own // is not paused, the deferred run is started immediately. It runs on its own
// goroutine. // goroutine.
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) { func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir) record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir)
s.mu.Lock() s.mu.Lock()
var cleanupErr, saveErr error var cleanupErr, saveErr error
var rerunStarted bool
if current := s.findByIDLocked(jobCopy.ID); current != nil { if current := s.findByIDLocked(jobCopy.ID); current != nil {
runtime := s.runtimeForLocked(current) runtime := s.runtimeForLocked(current)
runtime.LastRun = record.Time runtime.LastRun = record.Time
@@ -143,15 +172,19 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
if rerun { if rerun {
// A scheduled occurrence fired while this run was active under the // A scheduled occurrence fired while this run was active under the
// "queue" policy; start that deferred run now. // "queue" policy; start that deferred run now.
saveErr = s.startRunLocked(current, runtime, "Schedule") saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
rerunStarted = saveErr == nil
} else { } else {
s.refreshNextRunLocked(current, runtime) s.refreshNextRunLocked(current, runtime)
saveErr = s.store.SaveJobs(s.jobs) saveErr = s.store.SaveJobs(s.jobs)
} }
cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
} }
s.mu.Unlock() s.mu.Unlock()
if logErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
}
if cleanupErr != nil { if cleanupErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)}) s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
} }
@@ -159,7 +192,9 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)}) s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
} }
s.emit(RunRecorded{Record: record}) s.emit(RunRecorded{Record: record})
if !rerunStarted {
s.emit(JobChanged{JobID: jobCopy.ID}) s.emit(JobChanged{JobID: jobCopy.ID})
}
} }
// effectiveOverlapPolicy resolves the overlap policy that actually governs a // effectiveOverlapPolicy resolves the overlap policy that actually governs a
+16 -16
View File
@@ -116,10 +116,10 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -157,10 +157,10 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -194,11 +194,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -240,11 +240,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -297,11 +297,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -347,11 +347,11 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1) atomic.AddInt32(&calls, 1)
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -395,10 +395,10 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
@@ -437,10 +437,10 @@ 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 { svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
entered <- job.ID entered <- job.ID
<-release <-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"} return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
} }
done := completions(svc) done := completions(svc)
+1 -1
View File
@@ -51,7 +51,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 runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (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.
+3 -2
View File
@@ -3,7 +3,7 @@ package domain
import "time" import "time"
// JobRuntime is the transient execution state for a Job. It is never written to // JobRuntime is the transient execution state for a Job. It is never written to
// jobs.yaml: it is rebuilt from scratch each time GoSentry starts and is held in // jobs.json: it is rebuilt from scratch each time GoSentry starts and is held in
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate // memory keyed by Job.ID for the lifetime of the process. Keeping it separate
// from Job is what lets the durable configuration file stay free of run records, // from Job is what lets the durable configuration file stay free of run records,
// status strings, and scheduling bookkeeping. // status strings, and scheduling bookkeeping.
@@ -20,7 +20,8 @@ type JobRuntime struct {
NextDue time.Time NextDue time.Time
// Pending is set when a run was skipped due to the overlap policy being // Pending is set when a run was skipped due to the overlap policy being
// "queue". The scheduler will start this job as soon as the current run ends. // "queue". At most one deferred run is remembered; executeRun starts it when
// the current run ends.
Pending bool Pending bool
// Execution-time statistics accumulated since the last process start. // Execution-time statistics accumulated since the last process start.
+6 -5
View File
@@ -1,6 +1,7 @@
package runner package runner
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,12 +12,12 @@ import (
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) string { func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) (string, error) {
if strings.TrimSpace(logsDir) == "" { if strings.TrimSpace(logsDir) == "" {
return "" return "", errors.New("logs directory is empty")
} }
if err := os.MkdirAll(logsDir, 0o755); err != nil { if err := os.MkdirAll(logsDir, 0o755); err != nil {
return "" return "", fmt.Errorf("create logs directory: %w", err)
} }
// The timestamp comes first so a plain directory listing is naturally sorted // The timestamp comes first so a plain directory listing is naturally sorted
// by run time. The job name is included for human scanning, but sanitized to // by run time. The job name is included for human scanning, but sanitized to
@@ -26,9 +27,9 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n", content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output) started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "" return "", fmt.Errorf("write log file: %w", err)
} }
return path return path, nil
} }
func sanitizeFileName(name string) string { func sanitizeFileName(name string) string {
+3 -3
View File
@@ -15,7 +15,7 @@ import (
const commandTimeout = 30 * time.Second 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 { func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) (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. A fixed timeout is a conservative first guardrail for a desktop
@@ -54,7 +54,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
now := time.Now() now := time.Now()
timestamp := now.Format("2006-01-02 15:04:05") timestamp := now.Format("2006-01-02 15:04:05")
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now) logFile, logErr := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
// The runner is now pure with respect to the job: it returns a RunRecord and // The runner is now pure with respect to the job: it returns a RunRecord and
// lets the caller fold that record into the job's JobRuntime. Run state no // lets the caller fold that record into the job's JobRuntime. Run state no
@@ -69,7 +69,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
LogFile: logFile, LogFile: logFile,
Output: output, Output: output,
DurationMS: durationMS, DurationMS: durationMS,
} }, logErr
} }
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) { func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
+36 -9
View File
@@ -27,7 +27,10 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
Command: echoCommand("header test output"), Command: echoCommand("header test output"),
} }
record := RunJob(context.Background(), &job, "Schedule", logsDir) record, err := RunJob(context.Background(), &job, "Schedule", logsDir)
if err != nil {
t.Fatal(err)
}
if record.LogFile == "" { if record.LogFile == "" {
t.Fatal("expected log file to be written") t.Fatal("expected log file to be written")
} }
@@ -74,7 +77,10 @@ func TestRunJobRecordFields(t *testing.T) {
Command: echoCommand("record field check"), Command: echoCommand("record field check"),
} }
record := RunJob(context.Background(), &job, "Schedule", t.TempDir()) record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir())
if err != nil {
t.Fatal(err)
}
if record.JobID != job.ID { if record.JobID != job.ID {
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID) t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
@@ -158,7 +164,10 @@ func TestRunJobWritesLogFile(t *testing.T) {
Command: echoCommand("hello from test"), Command: echoCommand("hello from test"),
} }
record := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir)
if err != nil {
t.Fatal(err)
}
if record.LogFile == "" { if record.LogFile == "" {
t.Fatal("expected log file path") t.Fatal("expected log file path")
} }
@@ -193,7 +202,10 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
} }
record := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir)
if err != nil {
t.Fatal(err)
}
if record.State != "OK" { if record.State != "OK" {
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
} }
@@ -222,7 +234,10 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
Command: scriptPath, Command: scriptPath,
} }
record := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir)
if err != nil {
t.Fatal(err)
}
if record.State != "OK" { if record.State != "OK" {
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
} }
@@ -244,7 +259,10 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
Arguments: "/C\necho separate arguments ok", Arguments: "/C\necho separate arguments ok",
} }
record := RunJob(context.Background(), &job, "Manual", logsDir) record, err := RunJob(context.Background(), &job, "Manual", logsDir)
if err != nil {
t.Fatal(err)
}
if record.State != "OK" { if record.State != "OK" {
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output) t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
} }
@@ -267,7 +285,10 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
job.Arguments = "/C\nexit /b 1" job.Arguments = "/C\nexit /b 1"
} }
record := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
if err != nil {
t.Fatal(err)
}
if record.State != "Failed" { if record.State != "Failed" {
t.Fatalf("expected non-zero exit code to fail, got state %q detail %q", record.State, record.Detail) t.Fatalf("expected non-zero exit code to fail, got state %q detail %q", record.State, record.Detail)
} }
@@ -291,7 +312,10 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
StartOnly: true, StartOnly: true,
} }
record := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
if err != nil {
t.Fatal(err)
}
if record.State != "OK" { if record.State != "OK" {
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail) t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
} }
@@ -312,7 +336,10 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
StartOnly: true, StartOnly: true,
} }
record := RunJob(context.Background(), &job, "Manual", t.TempDir()) record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
if err != nil {
t.Fatal(err)
}
if record.State != "Failed" { if record.State != "Failed" {
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail) t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
} }
+1 -1
View File
@@ -29,7 +29,7 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event { func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
var events []event var events []event
for _, current := range jobs { for _, current := range jobs {
// At startup this is usually empty because jobs.yaml does not persist // At startup this is usually empty because jobs.json does not persist
// runtime logs. The function still centralizes the merge for future // runtime logs. The function still centralizes the merge for future
// history loading from log metadata. // history loading from log metadata.
if rt := runtimes[current.ID]; rt != nil { if rt := runtimes[current.ID]; rt != nil {
+2 -2
View File
@@ -43,8 +43,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
history, refreshHistory := newHistoryView(&events) history, refreshHistory := newHistoryView(&events)
recordStartup := func(duration time.Duration, windowShown bool) { recordStartup := func(duration time.Duration, windowShown bool) {
// Startup is recorded as an in-memory History event instead of being // Startup is recorded as an in-memory History event instead of being
// persisted into jobs.yaml. It is session diagnostics, not durable job // persisted into jobs.json. It is session diagnostics, not durable job
// state, and keeping it ephemeral avoids polluting the human-editable YAML // state, and keeping it ephemeral avoids polluting the human-editable JSON
// file with process-lifetime bookkeeping. // file with process-lifetime bookkeeping.
detail := "Window shown in " + duration.Round(time.Millisecond).String() detail := "Window shown in " + duration.Round(time.Millisecond).String()
if !windowShown { if !windowShown {
+1 -1
View File
@@ -196,7 +196,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
// gap between them instead of merging into one block. // gap between them instead of merging into one block.
container.NewVBox( container.NewVBox(
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)),
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)), settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)), settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
settingsRow("Max log files", maxLogFiles), settingsRow("Max log files", maxLogFiles),