T2.3: Split domain.Job (durable) from domain.JobRuntime (transient)

Move all transient execution state off domain.Job into a new
domain.JobRuntime, keyed by job ID:

- domain: Job now holds only durable YAML fields; remove the yaml:"-"
  fields (LastRun/NextRun/LastState/Logs/Output) and NextDue. Add
  runtime.go with JobRuntime plus NewRuntime/NewRuntimes constructors,
  which now own the runtime-init logic moved out of normalizeJobs.
- runner: RunJob no longer mutates the job; it is pure and returns the
  RunRecord for the caller to fold into the runtime.
- scheduler: take a shared map[int]*JobRuntime and route status/next-run
  bookkeeping through runtimeFor(job); prepareNextRun writes a *JobRuntime.
- storage: normalizeJobs touches only durable config.
- gui: own the runtime map (NewRuntimes), share it with the scheduler,
  and read/write runtime state via runtimeFor; maintain the map by ID on
  add/edit/delete.
- tests: update scheduler/storage tests to the split; tidy a pre-existing
  import-order nit in scheduler.go.

This also satisfies T2.4 (storage load/save only Job, runtime init in
domain.NewRuntime, round-trip tests), since removing the fields forced it.
Runtime-map ownership remains GUI-side glue until T3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-19 00:08:05 +03:00
parent ca673f08f9
commit b1874845d5
9 changed files with 228 additions and 180 deletions
+3 -3
View File
@@ -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
+5 -17
View File
@@ -1,12 +1,10 @@
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"`
@@ -17,14 +15,4 @@ type Job struct {
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:"-"`
}
+49
View File
@@ -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
}
+75 -44
View File
@@ -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,
+6 -13
View File
@@ -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) {
+51 -28
View File
@@ -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")
}
+14 -12
View File
@@ -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)
}
}
+3 -17
View File
@@ -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.
}
}
+8 -32
View File
@@ -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,6 +155,9 @@ 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,
@@ -182,13 +165,6 @@ func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
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"},
},
},
}