Refactoring complete: v0.4.0 architectural milestone #1

Merged
mix merged 48 commits from docs/refactoring-plan into main 2026-06-22 08:05:10 +03:00
4 changed files with 28 additions and 8 deletions
Showing only changes of commit a9eea8cbe7 - Show all commits
+1 -1
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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})
}
+4
View File
@@ -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()
})
}))