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
+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")
}