T3.3: Add state-mutating operations to app.Service

Add src/app/operations.go with the seven intents that make the Service
the sole writer of job and runtime state: CreateJob, UpdateJob,
DeleteJob, SetEnabled, RunNow, SetGlobalPause, UpdateSettings. Each
returns error, persists through the store, and announces changes via
RunRecorded/JobChanged/SchedulerStateChanged events.

Extend the Service with a parsed-schedule cache, a global paused flag,
an injectable runJob seam (defaults to runner.RunJob) for testing the
run-now path, and a lifecycle ctx. Run and next-run timing now live in
the Service (duplicating the scheduler temporarily); T3.4 converts the
scheduler to drive the Service and removes the duplication.

Autostart is left to the caller until T5.2's injectable Manager; async
save errors in the run goroutine remain deferred to T5.1. Adds 12 tests
covering create/update/delete, enable/pause, global pause, run-now with
a fake runner, and settings persistence/validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-19 07:48:41 +03:00
parent 5e51381b7a
commit d8ab9acf7e
4 changed files with 753 additions and 12 deletions
+37 -11
View File
@@ -1,9 +1,11 @@
package app
import (
"context"
"sync"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
@@ -13,21 +15,38 @@ import (
// to that state goes through a mutex so the GUI and the scheduler can no longer
// race on a shared *[]Job.
//
// This is the first slice of the layer (T3.1): it establishes ownership and the
// locking contract. State-mutating intents (CreateJob, RunNow, SetGlobalPause,
// ...) and the event/observer machinery are added in later tasks; for now the
// Service only owns state and exposes read snapshots.
// State ownership and the locking contract were established in T3.1; the
// event/observer machinery in T3.2. T3.3 adds the state-mutating intents
// (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause,
// UpdateSettings) in operations.go: the Service is now the sole writer of job
// and runtime state, persisting through the store and announcing changes via
// events.
//
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
// it; unexported helpers ending in "Locked" assume the caller already holds it.
// The Service must never call back into the UI (or any code that might re-enter
// the Service) while holding mu.
// the Service) while holding mu — in particular emit() is always called after
// mu is released.
type Service struct {
mu sync.Mutex
store *storage.Store
jobs []domain.Job
runtimes map[int]*domain.JobRuntime
// schedules caches a parsed Schedule per job ID so timing math does not
// re-parse the schedule string on every use. paused is the global pause flag.
// Both are guarded by mu. (The scheduler still keeps its own copy until T3.4
// converts it to drive the Service instead of sharing state.)
schedules map[int]domain.Schedule
paused bool
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
// tests with a fake so the run-now path can be exercised without spawning real
// processes. ctx is the lifecycle context passed to runs; T3.4 wires a
// cancelable Start/Stop, for now it is context.Background().
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
ctx context.Context
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
@@ -37,14 +56,21 @@ type Service struct {
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists. The store is the Service's sole channel
// to persistence.
// from the moment the Service exists, and parses each job's schedule once. The
// store is the Service's sole channel to persistence.
func NewService(store *storage.Store, jobs []domain.Job) *Service {
return &Service{
store: store,
jobs: jobs,
runtimes: domain.NewRuntimes(jobs),
s := &Service{
store: store,
jobs: jobs,
runtimes: domain.NewRuntimes(jobs),
schedules: make(map[int]domain.Schedule, len(jobs)),
runJob: runner.RunJob,
ctx: context.Background(),
}
for index := range s.jobs {
s.parseScheduleLocked(&s.jobs[index])
}
return s
}
// Open loads the store and constructs a Service from it in one step. It is the