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
+13 -37
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,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,
},
}