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:
+37
-11
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user