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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-7
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user