From a9eea8cbe7a44e5df63e69e852d79155c816e4c3 Mon Sep 17 00:00:00 2001 From: mixeme Date: Mon, 22 Jun 2026 07:17:22 +0300 Subject: [PATCH] T5.1: Surface background save/cleanup errors via ErrorOccurred event Replace the two _ = discards in executeRun (SaveJobs + CleanupLogs after an async run) and the _ = in RunDue (SaveJobs before a scheduled run) with captured errors emitted as ErrorOccurred events after the state lock is released. The UI subscriber in mainwindow.go handles the new event by appending an "Error" record to History so failed saves are visible to the user instead of silently dropped. Co-Authored-By: Claude Sonnet 4.6 --- docs/REFACTORING.md | 2 +- src/app/events.go | 9 +++++++++ src/app/operations.go | 21 ++++++++++++++------- src/ui/mainwindow.go | 4 ++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index b712451..8ff3c48 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -277,7 +277,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T4.7 — Confirm app.go is gone; smoke test both platforms ### Phase 5 — Hardening & docs -- [ ] T5.1 — Surface errors from service + storage +- [x] T5.1 — Surface errors from service + storage - [ ] T5.2 — Introduce `autostart.Manager` interface - [ ] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency) - [ ] T5.4 — Run `go test -race ./...` clean on both platforms diff --git a/src/app/events.go b/src/app/events.go index 6466ae8..5fe3ac9 100644 --- a/src/app/events.go +++ b/src/app/events.go @@ -41,9 +41,18 @@ type SchedulerStateChanged struct { Paused bool } +// ErrorOccurred signals a background error that could not be returned to a +// caller — typically a failed save or cleanup after an async run. The UI +// surfaces it in the History tab so the user is not silently left with +// un-persisted state. +type ErrorOccurred struct { + Err error +} + func (JobChanged) isEvent() {} func (RunRecorded) isEvent() {} func (SchedulerStateChanged) isEvent() {} +func (ErrorOccurred) isEvent() {} // Observer receives events emitted by the Service. OnEvent is the single // reaction point; the UI implements it and marshals any widget work onto the diff --git a/src/app/operations.go b/src/app/operations.go index cb9ba68..2b1f8ab 100644 --- a/src/app/operations.go +++ b/src/app/operations.go @@ -211,6 +211,7 @@ func (s *Service) RunNow(id int) error { func (s *Service) RunDue(now time.Time) { s.mu.Lock() var startedID int + var startErr error if !s.paused { for index := range s.jobs { job := &s.jobs[index] @@ -221,15 +222,16 @@ func (s *Service) RunDue(now time.Time) { if runtime.LastState == "Running" { continue } - // Async save errors cannot be returned to a caller here; surfacing them - // is deferred to T5.1 with the rest of the swallowed saves. - _ = s.startRunLocked(job, runtime, "Schedule") + startErr = s.startRunLocked(job, runtime, "Schedule") startedID = job.ID break } } s.mu.Unlock() + if startErr != nil { + s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)}) + } if startedID != 0 { s.emit(JobChanged{JobID: startedID}) } @@ -286,6 +288,7 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir) s.mu.Lock() + var cleanupErr, saveErr error if current := s.findByIDLocked(jobCopy.ID); current != nil { runtime := s.runtimeForLocked(current) runtime.LastRun = record.Time @@ -293,13 +296,17 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st runtime.Output = record.Output prependLog(runtime, record) s.refreshNextRunLocked(current, runtime) - // Async save errors cannot be returned to a caller; surfacing them is - // deferred to T5.1 along with the rest of the swallowed saves. - _ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) - _ = s.store.SaveJobs(s.jobs) + cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) + saveErr = s.store.SaveJobs(s.jobs) } s.mu.Unlock() + if cleanupErr != nil { + s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)}) + } + if saveErr != nil { + s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)}) + } s.emit(RunRecorded{Record: record}) s.emit(JobChanged{JobID: jobCopy.ID}) } diff --git a/src/ui/mainwindow.go b/src/ui/mainwindow.go index d69816f..37a4878 100644 --- a/src/ui/mainwindow.go +++ b/src/ui/mainwindow.go @@ -68,10 +68,14 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { // the UI thread. This is the sole place events touch widgets. (Resolves #4.) svc.Subscribe(app.ObserverFunc(func(ev app.Event) { recorded, isRecorded := ev.(app.RunRecorded) + errOccurred, isError := ev.(app.ErrorOccurred) fyne.Do(func() { if isRecorded { events = append(events, recorded.Record) } + if isError { + events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error())) + } refresh() }) }))