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() }) }))