diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index 243d1da..fa77079 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -192,7 +192,7 @@ Mechanical moves + import fixes only. Behavior identical. | T2.1 | Add `src/domain/schedule.go`: `Schedule` value object with `Parse`, `Validate`, `Next(time.Time)`. Unit-test it. Keep `nextRunTime` as a thin wrapper initially. | opus | high | | T2.2 | Migrate `scheduler` to use `Schedule` (parse on load/edit, not per tick). Remove duplicated parsing. | sonnet | medium | | T2.3 | Split `domain.Job` (durable) from `domain.JobRuntime` (transient). Remove all `yaml:"-"` fields and `nextDue` from `Job`. Add `runtime.go`. | opus | high | -| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. | sonnet | medium | +| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. **(Completed as part of T2.3 — removing the runtime fields from `Job` forced all three deliverables. Runtime-map ownership is deferred to T3.1.)** | sonnet | medium | > After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime` > split is wired through temporary glue. Phase 3 removes the sharing. @@ -256,8 +256,8 @@ Track progress here. Mark tasks complete as they land and pass review. ### Phase 2 — Domain cleanup - [x] T2.1 — Add `src/domain/schedule.go`; Schedule value object - [x] T2.2 — Migrate `scheduler` to use Schedule -- [ ] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient) -- [ ] T2.4 — Update `storage`: load/save Job only; move runtime init +- [x] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient) +- [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_ ### Phase 3 — Application service layer - [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex diff --git a/src/domain/job.go b/src/domain/job.go index b8294d8..0731713 100644 --- a/src/domain/job.go +++ b/src/domain/job.go @@ -1,30 +1,18 @@ package domain -import "time" - -// Job is the user-visible scheduled command. -// -// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI -// while GoSentry is running, but writing them to jobs.yaml would make the jobs -// file noisy and would mix durable configuration with transient execution state. +// Job is the user-visible scheduled command. It contains only durable +// configuration: every field is persisted to jobs.yaml. Transient execution +// state (last run, next run, command output, in-memory activity) lives in a +// separate JobRuntime so the jobs file stays a clean, hand-editable record of +// configuration and never mixes in process-lifetime bookkeeping. type Job struct { - ID int `yaml:"id"` - Name string `yaml:"name"` - Folder string `yaml:"folder,omitempty"` - Schedule string `yaml:"schedule"` - Command string `yaml:"command"` - Arguments string `yaml:"arguments,omitempty"` - SuccessExitCodes string `yaml:"success_exit_codes,omitempty"` - StartOnly bool `yaml:"start_only,omitempty"` - Enabled bool `yaml:"enabled"` - LastRun string `yaml:"-"` - NextRun string `yaml:"-"` - LastState string `yaml:"-"` - Logs []RunRecord `yaml:"-"` - Output string `yaml:"-"` - - // NextDue is kept as time.Time for scheduler comparisons. The formatted - // NextRun string above exists only for display in the GUI and YAML rewriting - // must not persist it. - NextDue time.Time `yaml:"-"` + ID int `yaml:"id"` + Name string `yaml:"name"` + Folder string `yaml:"folder,omitempty"` + Schedule string `yaml:"schedule"` + Command string `yaml:"command"` + Arguments string `yaml:"arguments,omitempty"` + SuccessExitCodes string `yaml:"success_exit_codes,omitempty"` + StartOnly bool `yaml:"start_only,omitempty"` + Enabled bool `yaml:"enabled"` } diff --git a/src/domain/runtime.go b/src/domain/runtime.go new file mode 100644 index 0000000..35934be --- /dev/null +++ b/src/domain/runtime.go @@ -0,0 +1,49 @@ +package domain + +import "time" + +// 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 +// 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, +// status strings, and scheduling bookkeeping. +type JobRuntime struct { + LastRun string + NextRun string + LastState string + Output string + Logs []RunRecord + + // NextDue is the next scheduled execution time, kept as time.Time for + // scheduler comparisons. NextRun above is its formatted display string and is + // the only form shown in the GUI. + NextDue time.Time +} + +// NewRuntime builds the initial runtime state for a freshly loaded or created +// job. Enabled jobs start "Ready" and wait for the scheduler to compute their +// first run; disabled jobs start "Paused". +func NewRuntime(job Job) *JobRuntime { + runtime := &JobRuntime{ + LastRun: "Never", + Output: "No command output captured yet.", + } + if job.Enabled { + runtime.LastState = "Ready" + runtime.NextRun = "After start" + } else { + runtime.LastState = "Paused" + runtime.NextRun = "Paused" + } + return runtime +} + +// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is +// the convenience entry point used when a whole jobs file has just been loaded. +func NewRuntimes(jobs []Job) map[int]*JobRuntime { + runtimes := make(map[int]*JobRuntime, len(jobs)) + for _, job := range jobs { + runtimes[job.ID] = NewRuntime(job) + } + return runtimes +} diff --git a/src/gui/app.go b/src/gui/app.go index 373c2a4..53caf71 100644 --- a/src/gui/app.go +++ b/src/gui/app.go @@ -172,7 +172,21 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil { store.Paths.DesktopIcon = iconPath } - events := collectActivity(jobs) + + // Transient execution state lives in a runtime map keyed by job ID, separate + // from the durable jobs slice. The scheduler shares the same map so background + // runs and GUI edits observe one in-memory copy of each job's status. + runtimes := domain.NewRuntimes(jobs) + runtimeFor := func(index int) *domain.JobRuntime { + if index < 0 || index >= len(jobs) { + return &domain.JobRuntime{} + } + if runtime := runtimes[jobs[index].ID]; runtime != nil { + return runtime + } + return &domain.JobRuntime{} + } + events := collectActivity(jobs, runtimes) // The GUI keeps the loaded jobs slice in memory and persists changes after // each edit/run. This keeps the first version responsive and easy to reason @@ -190,12 +204,13 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { arguments := newJobDetailLabel(jobs[selected].Arguments) successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes)) runMode := newJobDetailLabel(displayRunMode(jobs[selected])) - lastRun := newJobDetailLabel(jobs[selected].LastRun) - nextRun := newJobDetailLabel(jobs[selected].NextRun) - state := newJobDetailLabel(jobs[selected].LastState) + selectedRuntime := runtimeFor(selected) + lastRun := newJobDetailLabel(selectedRuntime.LastRun) + nextRun := newJobDetailLabel(selectedRuntime.NextRun) + state := newJobDetailLabel(selectedRuntime.LastState) schedulerState := widget.NewLabel("Scheduler running") commandOutput := widget.NewTextGrid() - commandOutput.SetText(jobs[selected].Output) + commandOutput.SetText(selectedRuntime.Output) commandOutputScroll := container.NewScroll(commandOutput) // Command output can contain long lines and preserved whitespace. TextGrid is // used instead of Label so stdout/stderr remains readable and does not vanish @@ -214,7 +229,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { events = append(events, newEvent(0, "Application", "Started", detail)) history.Refresh() } - selectedLogs := append([]event(nil), jobs[selected].Logs...) + selectedLogs := append([]event(nil), selectedRuntime.Logs...) jobLogs := widget.NewList( func() int { return len(selectedLogs) @@ -245,6 +260,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { } selected = index current := jobs[selected] + runtime := runtimeFor(selected) title.SetText(current.Name) folder.SetText(displayFolder(current.Folder)) schedule.SetText(current.Schedule) @@ -252,11 +268,11 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { arguments.SetText(displayArguments(current.Arguments)) successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) runMode.SetText(displayRunMode(current)) - lastRun.SetText(current.LastRun) - nextRun.SetText(current.NextRun) - state.SetText(current.LastState) - commandOutput.SetText(current.Output) - selectedLogs = append(selectedLogs[:0], current.Logs...) + lastRun.SetText(runtime.LastRun) + nextRun.SetText(runtime.NextRun) + state.SetText(runtime.LastState) + commandOutput.SetText(runtime.Output) + selectedLogs = append(selectedLogs[:0], runtime.Logs...) } refresh := func() { // Several callbacks mutate jobs, filters, and event history. A single @@ -288,7 +304,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { // Keep each row compact: folder, schedule, and command are shown in one // metadata line so the left pane stays useful even with many jobs. meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current)) - status.SetText(statusText(current)) + status.SetText(statusText(current, runtimes[current.ID])) }, ) list.OnSelected = func(id widget.ListItemID) { @@ -321,15 +337,17 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { folderSelect.SetSelected(selectedFolder) addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { - showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { + showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) { saved.ID = nextJobID nextJobID++ jobs = append(jobs, saved) + runtime := domain.NewRuntime(saved) + runtimes[saved.ID] = runtime selected = len(jobs) - 1 created := newEvent(saved.ID, saved.Name, "Created", "Job was added") // UI events are kept in memory for the current session. They explain // user actions in History, while command output remains in log files. - jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) + runtime.Logs = append([]event{created}, runtime.Logs...) events = append(events, created) _ = store.SaveJobs(jobs) folderSelect.Options = folderOptions(jobs) @@ -351,11 +369,25 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { } showJobDialog(w, "Edit job", jobs[selected], func(saved job) { saved.ID = jobs[selected].ID - saved.Logs = jobs[selected].Logs - saved.Output = jobs[selected].Output jobs[selected] = saved + // Runtime state (activity, output, status) is keyed by job ID and the ID + // is unchanged, so it survives the edit automatically. Reflect a possible + // enabled/disabled change into the status; the scheduler recomputes the + // next-run string below. + runtime := runtimes[saved.ID] + if runtime != nil { + if saved.Enabled { + if runtime.LastState == "" || runtime.LastState == "Paused" { + runtime.LastState = "Ready" + } + } else { + runtime.LastState = "Paused" + } + } updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed") - jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...) + if runtime != nil { + runtime.Logs = append([]event{updated}, runtime.Logs...) + } events = append(events, updated) if sched != nil { sched.RefreshSchedule(selected) @@ -391,8 +423,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { stopAllButton.SetText("Resume all") stopAllButton.SetIcon(theme.MediaPlayIcon()) for index := range jobs { - if jobs[index].Enabled { - jobs[index].NextRun = "Scheduler paused" + if runtime := runtimes[jobs[index].ID]; runtime != nil && jobs[index].Enabled { + runtime.NextRun = "Scheduler paused" } } if sched != nil { @@ -404,10 +436,11 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { stopAllButton.SetText("Pause all") stopAllButton.SetIcon(theme.MediaStopIcon()) for index := range jobs { - if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" { + runtime := runtimes[jobs[index].ID] + if runtime != nil && jobs[index].Enabled && runtime.NextRun == "Scheduler paused" { // The scheduler will calculate the exact next run when it is // resumed; this interim text prevents a stale paused timestamp. - jobs[index].NextRun = "Waiting for scheduler" + runtime.NextRun = "Waiting for scheduler" } } if sched != nil { @@ -424,20 +457,21 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { } current := &jobs[selected] current.Enabled = !current.Enabled + runtime := runtimeFor(selected) if current.Enabled { - current.LastState = "Ready" - current.NextRun = "Waiting for scheduler" + runtime.LastState = "Ready" + runtime.NextRun = "Waiting for scheduler" resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled") - current.Logs = append([]event{resumed}, current.Logs...) + runtime.Logs = append([]event{resumed}, runtime.Logs...) events = append(events, resumed) if sched != nil { sched.RefreshSchedule(selected) } } else { - current.LastState = "Paused" - current.NextRun = "Paused" + runtime.LastState = "Paused" + runtime.NextRun = "Paused" paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled") - current.Logs = append([]event{paused}, current.Logs...) + runtime.Logs = append([]event{paused}, runtime.Logs...) events = append(events, paused) if sched != nil { sched.RefreshSchedule(selected) @@ -459,6 +493,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { return } jobs = append(jobs[:selected], jobs[selected+1:]...) + delete(runtimes, deleted.ID) folderSelect.Options = folderOptions(jobs) folderSelect.Refresh() filteredJobs = filteredJobIndexes(jobs, selectedFolder) @@ -507,7 +542,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { jobLogs, ) - sched = scheduler.NewScheduler(store, &jobs, func(record domain.RunRecord) { + sched = scheduler.NewScheduler(store, &jobs, runtimes, func(record domain.RunRecord) { // Scheduled runs happen on the scheduler goroutine. The callback updates // the shared in-memory event list so History reflects background activity. events = append(events, record) @@ -559,11 +594,14 @@ func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) } } -func statusText(j job) string { +func statusText(j job, runtime *domain.JobRuntime) string { if !j.Enabled { return "Paused" } - return j.LastState + if runtime == nil { + return "" + } + return runtime.LastState } func newEvent(jobID int, jobName string, state string, detail string) event { @@ -590,13 +628,15 @@ func eventText(e event) string { return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail) } -func collectActivity(jobs []job) []event { +func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event { var events []event for _, current := range jobs { // At startup this is usually empty because jobs.yaml does not persist // runtime logs. The function still centralizes the merge for future // history loading from log metadata. - events = append(events, current.Logs...) + if runtime := runtimes[current.ID]; runtime != nil { + events = append(events, runtime.Logs...) + } } sort.SliceStable(events, func(left int, right int) bool { return events[left].Time < events[right].Time @@ -778,18 +818,9 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { } current.StartOnly = startOnly.Checked current.Enabled = enabled.Checked - if current.LastRun == "" { - current.LastRun = "Never" - } - if current.Enabled { - current.NextRun = "Waiting for scheduler" - if current.LastState == "" || current.LastState == "Paused" { - current.LastState = "Ready" - } - } else { - current.NextRun = "Paused" - current.LastState = "Paused" - } + // The dialog only edits durable configuration now. Runtime status is + // initialized (new jobs) or updated (edits) by the caller against the + // runtime map, keyed by job ID. onSave(current) }, w, diff --git a/src/runner/runner.go b/src/runner/runner.go index e49433a..873d607 100644 --- a/src/runner/runner.go +++ b/src/runner/runner.go @@ -49,13 +49,14 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string } now := time.Now() - job.LastRun = now.Format("2006-01-02 15:04:05") - job.LastState = state - job.Output = output + timestamp := now.Format("2006-01-02 15:04:05") logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now) - record := domain.RunRecord{ - Time: job.LastRun, + // 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 + // longer lives on Job, so there is nothing on the job to mutate here. + return domain.RunRecord{ + Time: timestamp, JobID: job.ID, JobName: job.Name, Trigger: trigger, @@ -64,14 +65,6 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string LogFile: logFile, Output: output, } - // Keep a small in-memory history for the currently running GUI. Full command - // output is persisted to files, so retaining every past record in RAM would - // only duplicate data and make long sessions grow without bound. - job.Logs = append([]domain.RunRecord{record}, job.Logs...) - if len(job.Logs) > 50 { - job.Logs = job.Logs[:50] - } - return record } func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) { diff --git a/src/scheduler/scheduler.go b/src/scheduler/scheduler.go index f192fc8..ec24ba5 100644 --- a/src/scheduler/scheduler.go +++ b/src/scheduler/scheduler.go @@ -8,8 +8,8 @@ import ( "time" "gitea.mixdep.ru/mix/gosentry/src/domain" - "gitea.mixdep.ru/mix/gosentry/src/storage" "gitea.mixdep.ru/mix/gosentry/src/runner" + "gitea.mixdep.ru/mix/gosentry/src/storage" ) // Scheduler owns the timing loop for jobs that are currently loaded in the GUI. @@ -19,6 +19,7 @@ import ( type Scheduler struct { store *storage.Store jobs *[]domain.Job + runtimes map[int]*domain.JobRuntime onChange func(domain.RunRecord) mu sync.Mutex @@ -28,11 +29,15 @@ type Scheduler struct { schedules map[int]domain.Schedule // parsed once per job on load/edit } -func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain.RunRecord)) *Scheduler { +// NewScheduler shares the durable jobs slice and the transient runtime map with +// the GUI. Both still point at the same in-memory state for now; Phase 3 moves +// ownership behind an application service. +func NewScheduler(store *storage.Store, jobs *[]domain.Job, runtimes map[int]*domain.JobRuntime, onChange func(domain.RunRecord)) *Scheduler { ctx, cancel := context.WithCancel(context.Background()) s := &Scheduler{ store: store, jobs: jobs, + runtimes: runtimes, onChange: onChange, ctx: ctx, cancel: cancel, @@ -42,6 +47,18 @@ func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain return s } +// runtimeFor returns the runtime state for a job, lazily creating it if the map +// has no entry yet. This keeps the scheduler robust if a job is added to the +// shared slice without a matching runtime. +func (s *Scheduler) runtimeFor(job *domain.Job) *domain.JobRuntime { + runtime, ok := s.runtimes[job.ID] + if !ok || runtime == nil { + runtime = domain.NewRuntime(*job) + s.runtimes[job.ID] = runtime + } + return runtime +} + func (s *Scheduler) Start() { // A one-second ticker is accurate enough for cron-style desktop automation // and avoids the complexity of maintaining one timer per job. Five-field cron @@ -75,15 +92,16 @@ func (s *Scheduler) SetPaused(paused bool) { // understandable even before the next scheduler tick. for index := range *s.jobs { job := &(*s.jobs)[index] + runtime := s.runtimeFor(job) if !job.Enabled { - job.NextRun = "Paused" + runtime.NextRun = "Paused" continue } if paused { - job.NextRun = "Scheduler paused" + runtime.NextRun = "Scheduler paused" continue } - s.prepareNextRun(job, now) + s.prepareNextRun(job, runtime, now) } _ = s.store.SaveJobs(*s.jobs) } @@ -109,16 +127,17 @@ func (s *Scheduler) RefreshSchedule(index int) { return } job := &(*s.jobs)[index] + runtime := s.runtimeFor(job) s.parseJobSchedule(job) // re-parse in case the schedule string changed if !job.Enabled { - job.NextRun = "Paused" + runtime.NextRun = "Paused" return } if s.paused { - job.NextRun = "Scheduler paused" + runtime.NextRun = "Scheduler paused" return } - s.prepareNextRun(job, time.Now()) + s.prepareNextRun(job, runtime, time.Now()) } func (s *Scheduler) tick(now time.Time) { @@ -128,7 +147,8 @@ func (s *Scheduler) tick(now time.Time) { if !s.paused { for index := range *s.jobs { job := &(*s.jobs)[index] - if !job.Enabled || job.NextDue.IsZero() || now.Before(job.NextDue) { + runtime := s.runtimeFor(job) + if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) { continue } // Run only one due job per tick for now. That avoids overlapping shell @@ -145,15 +165,16 @@ func (s *Scheduler) tick(now time.Time) { func (s *Scheduler) startRunLocked(index int, trigger string) bool { job := &(*s.jobs)[index] - if job.LastState == "Running" { + runtime := s.runtimeFor(job) + if runtime.LastState == "Running" { return false } jobCopy := *job - job.LastState = "Running" - job.NextRun = "Running" - job.Output = runningOutput(jobCopy, trigger, time.Now()) - job.NextDue = time.Time{} + runtime.LastState = "Running" + runtime.NextRun = "Running" + runtime.Output = runningOutput(jobCopy, trigger, time.Now()) + runtime.NextDue = time.Time{} _ = s.store.SaveJobs(*s.jobs) go func() { @@ -161,14 +182,15 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool { s.mu.Lock() if current := s.findJobByIDLocked(jobCopy.ID); current != nil { - current.LastRun = record.Time - current.LastState = record.State - current.Output = record.Output - current.Logs = append([]domain.RunRecord{record}, current.Logs...) - if len(current.Logs) > 50 { - current.Logs = current.Logs[:50] + currentRuntime := s.runtimeFor(current) + currentRuntime.LastRun = record.Time + currentRuntime.LastState = record.State + currentRuntime.Output = record.Output + currentRuntime.Logs = append([]domain.RunRecord{record}, currentRuntime.Logs...) + if len(currentRuntime.Logs) > 50 { + currentRuntime.Logs = currentRuntime.Logs[:50] } - s.prepareNextRun(current, time.Now()) + s.prepareNextRun(current, currentRuntime, time.Now()) _ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) _ = s.store.SaveJobs(*s.jobs) } @@ -210,12 +232,13 @@ func runningOutput(job domain.Job, trigger string, started time.Time) string { func (s *Scheduler) resetNextRuns(now time.Time) { for index := range *s.jobs { job := &(*s.jobs)[index] + runtime := s.runtimeFor(job) s.parseJobSchedule(job) // parse once on load if !job.Enabled { - job.NextRun = "Paused" + runtime.NextRun = "Paused" continue } - s.prepareNextRun(job, now) + s.prepareNextRun(job, runtime, now) } _ = s.store.SaveJobs(*s.jobs) } @@ -232,13 +255,13 @@ func (s *Scheduler) parseJobSchedule(job *domain.Job) { s.schedules[job.ID] = sched } -func (s *Scheduler) prepareNextRun(job *domain.Job, from time.Time) { +func (s *Scheduler) prepareNextRun(job *domain.Job, runtime *domain.JobRuntime, from time.Time) { sched, ok := s.schedules[job.ID] if !ok { - job.NextRun = "Invalid schedule" - job.NextDue = time.Time{} + runtime.NextRun = "Invalid schedule" + runtime.NextDue = time.Time{} return } - job.NextDue = sched.Next(from) - job.NextRun = job.NextDue.Format("2006-01-02 15:04:05") + runtime.NextDue = sched.Next(from) + runtime.NextRun = runtime.NextDue.Format("2006-01-02 15:04:05") } diff --git a/src/scheduler/scheduler_test.go b/src/scheduler/scheduler_test.go index 6daec2d..dea7ab3 100644 --- a/src/scheduler/scheduler_test.go +++ b/src/scheduler/scheduler_test.go @@ -10,35 +10,37 @@ import ( func TestPrepareNextRunSetsDisplayString(t *testing.T) { jobs := []domain.Job{{Schedule: "*/5 * * * *", Enabled: true}} - s := &Scheduler{jobs: &jobs, schedules: make(map[int]domain.Schedule)} + s := &Scheduler{jobs: &jobs, runtimes: domain.NewRuntimes(jobs), schedules: make(map[int]domain.Schedule)} s.parseJobSchedule(&jobs[0]) + runtime := s.runtimeFor(&jobs[0]) from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC) - s.prepareNextRun(&jobs[0], from) + s.prepareNextRun(&jobs[0], runtime, from) want := "2026-06-14 12:05:00" - if jobs[0].NextRun != want { - t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want) + if runtime.NextRun != want { + t.Errorf("NextRun: got %q, want %q", runtime.NextRun, want) } wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC) - if !jobs[0].NextDue.Equal(wantDue) { - t.Errorf("NextDue: got %v, want %v", jobs[0].NextDue, wantDue) + if !runtime.NextDue.Equal(wantDue) { + t.Errorf("NextDue: got %v, want %v", runtime.NextDue, wantDue) } } func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) { jobs := []domain.Job{{Schedule: "not-a-cron", Enabled: true}} - s := &Scheduler{jobs: &jobs, schedules: make(map[int]domain.Schedule)} + s := &Scheduler{jobs: &jobs, runtimes: domain.NewRuntimes(jobs), schedules: make(map[int]domain.Schedule)} // parseJobSchedule will drop the invalid spec, so schedules map stays empty. s.parseJobSchedule(&jobs[0]) + runtime := s.runtimeFor(&jobs[0]) - s.prepareNextRun(&jobs[0], time.Now()) + s.prepareNextRun(&jobs[0], runtime, time.Now()) - if jobs[0].NextRun != "Invalid schedule" { - t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun) + if runtime.NextRun != "Invalid schedule" { + t.Errorf("NextRun: got %q, want 'Invalid schedule'", runtime.NextRun) } - if !jobs[0].NextDue.IsZero() { - t.Errorf("NextDue should be zero for invalid schedule, got %v", jobs[0].NextDue) + if !runtime.NextDue.IsZero() { + t.Errorf("NextDue should be zero for invalid schedule, got %v", runtime.NextDue) } } diff --git a/src/storage/store.go b/src/storage/store.go index c5cfecc..e9fa495 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -169,23 +169,9 @@ func normalizeJobs(jobs []domain.Job) { if job.SuccessExitCodes == "" { job.SuccessExitCodes = "0" } - if job.LastRun == "" { - job.LastRun = "Never" - } - if job.Output == "" { - job.Output = "No command output captured yet." - } - if job.Enabled { - job.LastState = "Ready" - job.NextRun = "After start" - } else { - job.LastState = "Paused" - job.NextRun = "Paused" - } - // Runtime fields are reconstructed each time the app starts. Persisted run - // records live in log files, not in jobs.yaml, to keep the jobs file easy - // to review and edit by hand. - job.Logs = nil + // Runtime state (last run, next run, status, output, activity) is no longer + // part of Job. It is reconstructed each time the app starts via + // domain.NewRuntime, so normalizeJobs only touches durable configuration. } } diff --git a/src/storage/store_test.go b/src/storage/store_test.go index 4dd5c4e..bcaa2d9 100644 --- a/src/storage/store_test.go +++ b/src/storage/store_test.go @@ -68,16 +68,8 @@ func TestJobsRoundTrip(t *testing.T) { t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled) } - // Runtime fields must not survive the save→load round-trip. - if g.LastRun != "" { - t.Errorf("LastRun should be empty after load, got %q", g.LastRun) - } - if g.LastState != "" { - t.Errorf("LastState should be empty after load, got %q", g.LastState) - } - if g.Logs != nil { - t.Errorf("Logs should be nil after load, got %v", g.Logs) - } + // Runtime state no longer lives on Job at all (it moved to domain.JobRuntime), + // so there is nothing transient that could survive the save→load round-trip. } func TestConfigRoundTrip(t *testing.T) { @@ -137,7 +129,9 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) { normalizeJobs(jobs) - // Blank enabled job gets default name, schedule, command, exit codes, and runtime state. + // Blank enabled job gets default name, schedule, command, and exit codes. + // normalizeJobs only fills durable configuration now; runtime status is built + // separately by domain.NewRuntime. if jobs[0].ID != 1 { t.Errorf("first auto ID: got %d, want 1", jobs[0].ID) } @@ -150,20 +144,6 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) { if jobs[0].SuccessExitCodes != "0" { t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes) } - if jobs[0].LastState != "Ready" { - t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState) - } - if jobs[0].NextRun != "After start" { - t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun) - } - - // Disabled job is marked Paused. - if jobs[1].LastState != "Paused" { - t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState) - } - if jobs[1].NextRun != "Paused" { - t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun) - } // Pre-set fields survive normalization unchanged. if jobs[2].ID != 5 { @@ -175,20 +155,16 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) { } func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { + // Job carries only durable configuration; runtime state lives in + // domain.JobRuntime and is never marshalled. This guards against a future + // runtime field accidentally being added back onto Job with a yaml tag. jobs := []domain.Job{ { - ID: 1, - Name: "Clean job", - Schedule: "@every 10s", - Command: echoCommand("ok"), - Enabled: true, - LastRun: "2026-06-14 12:00:00", - NextRun: "2026-06-14 12:00:10", - LastState: "OK", - Output: "stdout: ok", - Logs: []domain.RunRecord{ - {Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"}, - }, + ID: 1, + Name: "Clean job", + Schedule: "@every 10s", + Command: echoCommand("ok"), + Enabled: true, }, }