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:
+3
-3
@@ -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.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.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.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`
|
> After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime`
|
||||||
> split is wired through temporary glue. Phase 3 removes the sharing.
|
> 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
|
### Phase 2 — Domain cleanup
|
||||||
- [x] T2.1 — Add `src/domain/schedule.go`; Schedule value object
|
- [x] T2.1 — Add `src/domain/schedule.go`; Schedule value object
|
||||||
- [x] T2.2 — Migrate `scheduler` to use Schedule
|
- [x] T2.2 — Migrate `scheduler` to use Schedule
|
||||||
- [ ] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
|
- [x] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
|
||||||
- [ ] T2.4 — Update `storage`: load/save Job only; move runtime init
|
- [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_
|
||||||
|
|
||||||
### Phase 3 — Application service layer
|
### Phase 3 — Application service layer
|
||||||
- [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex
|
- [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex
|
||||||
|
|||||||
+5
-17
@@ -1,12 +1,10 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import "time"
|
// Job is the user-visible scheduled command. It contains only durable
|
||||||
|
// configuration: every field is persisted to jobs.yaml. Transient execution
|
||||||
// Job is the user-visible scheduled command.
|
// 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
|
||||||
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
// configuration and never mixes in process-lifetime bookkeeping.
|
||||||
// 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.
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `yaml:"id"`
|
ID int `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -17,14 +15,4 @@ type Job struct {
|
|||||||
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||||
StartOnly bool `yaml:"start_only,omitempty"`
|
StartOnly bool `yaml:"start_only,omitempty"`
|
||||||
Enabled bool `yaml:"enabled"`
|
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:"-"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
|
||||||
store.Paths.DesktopIcon = iconPath
|
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
|
// 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
|
// 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)
|
arguments := newJobDetailLabel(jobs[selected].Arguments)
|
||||||
successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes))
|
successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes))
|
||||||
runMode := newJobDetailLabel(displayRunMode(jobs[selected]))
|
runMode := newJobDetailLabel(displayRunMode(jobs[selected]))
|
||||||
lastRun := newJobDetailLabel(jobs[selected].LastRun)
|
selectedRuntime := runtimeFor(selected)
|
||||||
nextRun := newJobDetailLabel(jobs[selected].NextRun)
|
lastRun := newJobDetailLabel(selectedRuntime.LastRun)
|
||||||
state := newJobDetailLabel(jobs[selected].LastState)
|
nextRun := newJobDetailLabel(selectedRuntime.NextRun)
|
||||||
|
state := newJobDetailLabel(selectedRuntime.LastState)
|
||||||
schedulerState := widget.NewLabel("Scheduler running")
|
schedulerState := widget.NewLabel("Scheduler running")
|
||||||
commandOutput := widget.NewTextGrid()
|
commandOutput := widget.NewTextGrid()
|
||||||
commandOutput.SetText(jobs[selected].Output)
|
commandOutput.SetText(selectedRuntime.Output)
|
||||||
commandOutputScroll := container.NewScroll(commandOutput)
|
commandOutputScroll := container.NewScroll(commandOutput)
|
||||||
// Command output can contain long lines and preserved whitespace. TextGrid is
|
// Command output can contain long lines and preserved whitespace. TextGrid is
|
||||||
// used instead of Label so stdout/stderr remains readable and does not vanish
|
// 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))
|
events = append(events, newEvent(0, "Application", "Started", detail))
|
||||||
history.Refresh()
|
history.Refresh()
|
||||||
}
|
}
|
||||||
selectedLogs := append([]event(nil), jobs[selected].Logs...)
|
selectedLogs := append([]event(nil), selectedRuntime.Logs...)
|
||||||
jobLogs := widget.NewList(
|
jobLogs := widget.NewList(
|
||||||
func() int {
|
func() int {
|
||||||
return len(selectedLogs)
|
return len(selectedLogs)
|
||||||
@@ -245,6 +260,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
}
|
}
|
||||||
selected = index
|
selected = index
|
||||||
current := jobs[selected]
|
current := jobs[selected]
|
||||||
|
runtime := runtimeFor(selected)
|
||||||
title.SetText(current.Name)
|
title.SetText(current.Name)
|
||||||
folder.SetText(displayFolder(current.Folder))
|
folder.SetText(displayFolder(current.Folder))
|
||||||
schedule.SetText(current.Schedule)
|
schedule.SetText(current.Schedule)
|
||||||
@@ -252,11 +268,11 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
arguments.SetText(displayArguments(current.Arguments))
|
arguments.SetText(displayArguments(current.Arguments))
|
||||||
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes))
|
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes))
|
||||||
runMode.SetText(displayRunMode(current))
|
runMode.SetText(displayRunMode(current))
|
||||||
lastRun.SetText(current.LastRun)
|
lastRun.SetText(runtime.LastRun)
|
||||||
nextRun.SetText(current.NextRun)
|
nextRun.SetText(runtime.NextRun)
|
||||||
state.SetText(current.LastState)
|
state.SetText(runtime.LastState)
|
||||||
commandOutput.SetText(current.Output)
|
commandOutput.SetText(runtime.Output)
|
||||||
selectedLogs = append(selectedLogs[:0], current.Logs...)
|
selectedLogs = append(selectedLogs[:0], runtime.Logs...)
|
||||||
}
|
}
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
// Several callbacks mutate jobs, filters, and event history. A single
|
// 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
|
// Keep each row compact: folder, schedule, and command are shown in one
|
||||||
// metadata line so the left pane stays useful even with many jobs.
|
// metadata line so the left pane stays useful even with many jobs.
|
||||||
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current))
|
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) {
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
@@ -321,15 +337,17 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
folderSelect.SetSelected(selectedFolder)
|
folderSelect.SetSelected(selectedFolder)
|
||||||
|
|
||||||
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
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
|
saved.ID = nextJobID
|
||||||
nextJobID++
|
nextJobID++
|
||||||
jobs = append(jobs, saved)
|
jobs = append(jobs, saved)
|
||||||
|
runtime := domain.NewRuntime(saved)
|
||||||
|
runtimes[saved.ID] = runtime
|
||||||
selected = len(jobs) - 1
|
selected = len(jobs) - 1
|
||||||
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
||||||
// UI events are kept in memory for the current session. They explain
|
// UI events are kept in memory for the current session. They explain
|
||||||
// user actions in History, while command output remains in log files.
|
// 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)
|
events = append(events, created)
|
||||||
_ = store.SaveJobs(jobs)
|
_ = store.SaveJobs(jobs)
|
||||||
folderSelect.Options = folderOptions(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) {
|
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
||||||
saved.ID = jobs[selected].ID
|
saved.ID = jobs[selected].ID
|
||||||
saved.Logs = jobs[selected].Logs
|
|
||||||
saved.Output = jobs[selected].Output
|
|
||||||
jobs[selected] = saved
|
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")
|
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)
|
events = append(events, updated)
|
||||||
if sched != nil {
|
if sched != nil {
|
||||||
sched.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
@@ -391,8 +423,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
stopAllButton.SetText("Resume all")
|
stopAllButton.SetText("Resume all")
|
||||||
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
if jobs[index].Enabled {
|
if runtime := runtimes[jobs[index].ID]; runtime != nil && jobs[index].Enabled {
|
||||||
jobs[index].NextRun = "Scheduler paused"
|
runtime.NextRun = "Scheduler paused"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sched != nil {
|
if sched != nil {
|
||||||
@@ -404,10 +436,11 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
stopAllButton.SetText("Pause all")
|
stopAllButton.SetText("Pause all")
|
||||||
stopAllButton.SetIcon(theme.MediaStopIcon())
|
stopAllButton.SetIcon(theme.MediaStopIcon())
|
||||||
for index := range jobs {
|
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
|
// The scheduler will calculate the exact next run when it is
|
||||||
// resumed; this interim text prevents a stale paused timestamp.
|
// resumed; this interim text prevents a stale paused timestamp.
|
||||||
jobs[index].NextRun = "Waiting for scheduler"
|
runtime.NextRun = "Waiting for scheduler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sched != nil {
|
if sched != nil {
|
||||||
@@ -424,20 +457,21 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
}
|
}
|
||||||
current := &jobs[selected]
|
current := &jobs[selected]
|
||||||
current.Enabled = !current.Enabled
|
current.Enabled = !current.Enabled
|
||||||
|
runtime := runtimeFor(selected)
|
||||||
if current.Enabled {
|
if current.Enabled {
|
||||||
current.LastState = "Ready"
|
runtime.LastState = "Ready"
|
||||||
current.NextRun = "Waiting for scheduler"
|
runtime.NextRun = "Waiting for scheduler"
|
||||||
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
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)
|
events = append(events, resumed)
|
||||||
if sched != nil {
|
if sched != nil {
|
||||||
sched.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current.LastState = "Paused"
|
runtime.LastState = "Paused"
|
||||||
current.NextRun = "Paused"
|
runtime.NextRun = "Paused"
|
||||||
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
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)
|
events = append(events, paused)
|
||||||
if sched != nil {
|
if sched != nil {
|
||||||
sched.RefreshSchedule(selected)
|
sched.RefreshSchedule(selected)
|
||||||
@@ -459,6 +493,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jobs = append(jobs[:selected], jobs[selected+1:]...)
|
jobs = append(jobs[:selected], jobs[selected+1:]...)
|
||||||
|
delete(runtimes, deleted.ID)
|
||||||
folderSelect.Options = folderOptions(jobs)
|
folderSelect.Options = folderOptions(jobs)
|
||||||
folderSelect.Refresh()
|
folderSelect.Refresh()
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
@@ -507,7 +542,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
jobLogs,
|
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
|
// Scheduled runs happen on the scheduler goroutine. The callback updates
|
||||||
// the shared in-memory event list so History reflects background activity.
|
// the shared in-memory event list so History reflects background activity.
|
||||||
events = append(events, record)
|
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 {
|
if !j.Enabled {
|
||||||
return "Paused"
|
return "Paused"
|
||||||
}
|
}
|
||||||
return j.LastState
|
if runtime == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return runtime.LastState
|
||||||
}
|
}
|
||||||
|
|
||||||
func newEvent(jobID int, jobName string, state string, detail string) event {
|
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)
|
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
|
var events []event
|
||||||
for _, current := range jobs {
|
for _, current := range jobs {
|
||||||
// At startup this is usually empty because jobs.yaml does not persist
|
// At startup this is usually empty because jobs.yaml does not persist
|
||||||
// runtime logs. The function still centralizes the merge for future
|
// runtime logs. The function still centralizes the merge for future
|
||||||
// history loading from log metadata.
|
// 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 {
|
sort.SliceStable(events, func(left int, right int) bool {
|
||||||
return events[left].Time < events[right].Time
|
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.StartOnly = startOnly.Checked
|
||||||
current.Enabled = enabled.Checked
|
current.Enabled = enabled.Checked
|
||||||
if current.LastRun == "" {
|
// The dialog only edits durable configuration now. Runtime status is
|
||||||
current.LastRun = "Never"
|
// initialized (new jobs) or updated (edits) by the caller against the
|
||||||
}
|
// runtime map, keyed by job ID.
|
||||||
if current.Enabled {
|
|
||||||
current.NextRun = "Waiting for scheduler"
|
|
||||||
if current.LastState == "" || current.LastState == "Paused" {
|
|
||||||
current.LastState = "Ready"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current.NextRun = "Paused"
|
|
||||||
current.LastState = "Paused"
|
|
||||||
}
|
|
||||||
onSave(current)
|
onSave(current)
|
||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
|
|||||||
+6
-13
@@ -49,13 +49,14 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
job.LastRun = now.Format("2006-01-02 15:04:05")
|
timestamp := now.Format("2006-01-02 15:04:05")
|
||||||
job.LastState = state
|
|
||||||
job.Output = output
|
|
||||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
||||||
|
|
||||||
record := domain.RunRecord{
|
// The runner is now pure with respect to the job: it returns a RunRecord and
|
||||||
Time: job.LastRun,
|
// 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,
|
JobID: job.ID,
|
||||||
JobName: job.Name,
|
JobName: job.Name,
|
||||||
Trigger: trigger,
|
Trigger: trigger,
|
||||||
@@ -64,14 +65,6 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
LogFile: logFile,
|
LogFile: logFile,
|
||||||
Output: output,
|
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) {
|
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||||
|
|||||||
+51
-28
@@ -8,8 +8,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"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/runner"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
jobs *[]domain.Job
|
jobs *[]domain.Job
|
||||||
|
runtimes map[int]*domain.JobRuntime
|
||||||
onChange func(domain.RunRecord)
|
onChange func(domain.RunRecord)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -28,11 +29,15 @@ type Scheduler struct {
|
|||||||
schedules map[int]domain.Schedule // parsed once per job on load/edit
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
s := &Scheduler{
|
s := &Scheduler{
|
||||||
store: store,
|
store: store,
|
||||||
jobs: jobs,
|
jobs: jobs,
|
||||||
|
runtimes: runtimes,
|
||||||
onChange: onChange,
|
onChange: onChange,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
@@ -42,6 +47,18 @@ func NewScheduler(store *storage.Store, jobs *[]domain.Job, onChange func(domain
|
|||||||
return s
|
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() {
|
func (s *Scheduler) Start() {
|
||||||
// A one-second ticker is accurate enough for cron-style desktop automation
|
// 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
|
// 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.
|
// understandable even before the next scheduler tick.
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
|
runtime := s.runtimeFor(job)
|
||||||
if !job.Enabled {
|
if !job.Enabled {
|
||||||
job.NextRun = "Paused"
|
runtime.NextRun = "Paused"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if paused {
|
if paused {
|
||||||
job.NextRun = "Scheduler paused"
|
runtime.NextRun = "Scheduler paused"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.prepareNextRun(job, now)
|
s.prepareNextRun(job, runtime, now)
|
||||||
}
|
}
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
@@ -109,16 +127,17 @@ func (s *Scheduler) RefreshSchedule(index int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
|
runtime := s.runtimeFor(job)
|
||||||
s.parseJobSchedule(job) // re-parse in case the schedule string changed
|
s.parseJobSchedule(job) // re-parse in case the schedule string changed
|
||||||
if !job.Enabled {
|
if !job.Enabled {
|
||||||
job.NextRun = "Paused"
|
runtime.NextRun = "Paused"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.paused {
|
if s.paused {
|
||||||
job.NextRun = "Scheduler paused"
|
runtime.NextRun = "Scheduler paused"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.prepareNextRun(job, time.Now())
|
s.prepareNextRun(job, runtime, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) tick(now time.Time) {
|
func (s *Scheduler) tick(now time.Time) {
|
||||||
@@ -128,7 +147,8 @@ func (s *Scheduler) tick(now time.Time) {
|
|||||||
if !s.paused {
|
if !s.paused {
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
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
|
continue
|
||||||
}
|
}
|
||||||
// Run only one due job per tick for now. That avoids overlapping shell
|
// 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 {
|
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
if job.LastState == "Running" {
|
runtime := s.runtimeFor(job)
|
||||||
|
if runtime.LastState == "Running" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
jobCopy := *job
|
jobCopy := *job
|
||||||
job.LastState = "Running"
|
runtime.LastState = "Running"
|
||||||
job.NextRun = "Running"
|
runtime.NextRun = "Running"
|
||||||
job.Output = runningOutput(jobCopy, trigger, time.Now())
|
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||||
job.NextDue = time.Time{}
|
runtime.NextDue = time.Time{}
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -161,14 +182,15 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
|||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
||||||
current.LastRun = record.Time
|
currentRuntime := s.runtimeFor(current)
|
||||||
current.LastState = record.State
|
currentRuntime.LastRun = record.Time
|
||||||
current.Output = record.Output
|
currentRuntime.LastState = record.State
|
||||||
current.Logs = append([]domain.RunRecord{record}, current.Logs...)
|
currentRuntime.Output = record.Output
|
||||||
if len(current.Logs) > 50 {
|
currentRuntime.Logs = append([]domain.RunRecord{record}, currentRuntime.Logs...)
|
||||||
current.Logs = current.Logs[:50]
|
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)
|
_ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = 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) {
|
func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||||
for index := range *s.jobs {
|
for index := range *s.jobs {
|
||||||
job := &(*s.jobs)[index]
|
job := &(*s.jobs)[index]
|
||||||
|
runtime := s.runtimeFor(job)
|
||||||
s.parseJobSchedule(job) // parse once on load
|
s.parseJobSchedule(job) // parse once on load
|
||||||
if !job.Enabled {
|
if !job.Enabled {
|
||||||
job.NextRun = "Paused"
|
runtime.NextRun = "Paused"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.prepareNextRun(job, now)
|
s.prepareNextRun(job, runtime, now)
|
||||||
}
|
}
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
@@ -232,13 +255,13 @@ func (s *Scheduler) parseJobSchedule(job *domain.Job) {
|
|||||||
s.schedules[job.ID] = sched
|
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]
|
sched, ok := s.schedules[job.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
job.NextRun = "Invalid schedule"
|
runtime.NextRun = "Invalid schedule"
|
||||||
job.NextDue = time.Time{}
|
runtime.NextDue = time.Time{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
job.NextDue = sched.Next(from)
|
runtime.NextDue = sched.Next(from)
|
||||||
job.NextRun = job.NextDue.Format("2006-01-02 15:04:05")
|
runtime.NextRun = runtime.NextDue.Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,35 +10,37 @@ import (
|
|||||||
|
|
||||||
func TestPrepareNextRunSetsDisplayString(t *testing.T) {
|
func TestPrepareNextRunSetsDisplayString(t *testing.T) {
|
||||||
jobs := []domain.Job{{Schedule: "*/5 * * * *", Enabled: true}}
|
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])
|
s.parseJobSchedule(&jobs[0])
|
||||||
|
runtime := s.runtimeFor(&jobs[0])
|
||||||
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
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"
|
want := "2026-06-14 12:05:00"
|
||||||
if jobs[0].NextRun != want {
|
if runtime.NextRun != want {
|
||||||
t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want)
|
t.Errorf("NextRun: got %q, want %q", runtime.NextRun, want)
|
||||||
}
|
}
|
||||||
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
||||||
if !jobs[0].NextDue.Equal(wantDue) {
|
if !runtime.NextDue.Equal(wantDue) {
|
||||||
t.Errorf("NextDue: got %v, want %v", jobs[0].NextDue, wantDue)
|
t.Errorf("NextDue: got %v, want %v", runtime.NextDue, wantDue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
|
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
|
||||||
jobs := []domain.Job{{Schedule: "not-a-cron", Enabled: true}}
|
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.
|
// parseJobSchedule will drop the invalid spec, so schedules map stays empty.
|
||||||
s.parseJobSchedule(&jobs[0])
|
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" {
|
if runtime.NextRun != "Invalid schedule" {
|
||||||
t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun)
|
t.Errorf("NextRun: got %q, want 'Invalid schedule'", runtime.NextRun)
|
||||||
}
|
}
|
||||||
if !jobs[0].NextDue.IsZero() {
|
if !runtime.NextDue.IsZero() {
|
||||||
t.Errorf("NextDue should be zero for invalid schedule, got %v", jobs[0].NextDue)
|
t.Errorf("NextDue should be zero for invalid schedule, got %v", runtime.NextDue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-17
@@ -169,23 +169,9 @@ func normalizeJobs(jobs []domain.Job) {
|
|||||||
if job.SuccessExitCodes == "" {
|
if job.SuccessExitCodes == "" {
|
||||||
job.SuccessExitCodes = "0"
|
job.SuccessExitCodes = "0"
|
||||||
}
|
}
|
||||||
if job.LastRun == "" {
|
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||||
job.LastRun = "Never"
|
// part of Job. It is reconstructed each time the app starts via
|
||||||
}
|
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,16 +68,8 @@ func TestJobsRoundTrip(t *testing.T) {
|
|||||||
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runtime fields must not survive the save→load round-trip.
|
// Runtime state no longer lives on Job at all (it moved to domain.JobRuntime),
|
||||||
if g.LastRun != "" {
|
// so there is nothing transient that could survive the save→load round-trip.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigRoundTrip(t *testing.T) {
|
func TestConfigRoundTrip(t *testing.T) {
|
||||||
@@ -137,7 +129,9 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
|||||||
|
|
||||||
normalizeJobs(jobs)
|
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 {
|
if jobs[0].ID != 1 {
|
||||||
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
|
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" {
|
if jobs[0].SuccessExitCodes != "0" {
|
||||||
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
|
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.
|
// Pre-set fields survive normalization unchanged.
|
||||||
if jobs[2].ID != 5 {
|
if jobs[2].ID != 5 {
|
||||||
@@ -175,6 +155,9 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJobsYAMLDoesNotPersistRuntimeNoise(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{
|
jobs := []domain.Job{
|
||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
@@ -182,13 +165,6 @@ func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
|||||||
Schedule: "@every 10s",
|
Schedule: "@every 10s",
|
||||||
Command: echoCommand("ok"),
|
Command: echoCommand("ok"),
|
||||||
Enabled: true,
|
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"},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user