package app import ( "context" "sync" "time" "gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/platform/autostart" "gitea.mixdep.ru/mix/gosentry/src/runner" "gitea.mixdep.ru/mix/gosentry/src/scheduler" "gitea.mixdep.ru/mix/gosentry/src/storage" ) // Service is the application-service layer: the single owner of GoSentry's // in-memory state. It holds the durable jobs slice, the transient runtime map // keyed by Job.ID, and a reference to the store that persists them. All access // to that state goes through a mutex so the GUI and the scheduler can no longer // race on a shared *[]Job. // // State ownership and the locking contract were established in T3.1; the // event/observer machinery in T3.2. T3.3 added the state-mutating intents // (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause, // UpdateSettings) in operations.go: the Service is the sole writer of job and // runtime state, persisting through the store and announcing changes via events. // // T3.4 makes the Service drive scheduling too. It owns the timing loop through a // scheduler.Scheduler that calls RunDue on every tick; the scheduler holds no // job state and never touches the slice directly. The old shared *[]domain.Job // between GUI and scheduler is gone — both go through the Service. // // 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 — 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. 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 paths can be exercised without spawning real // processes. ctx is the lifecycle context passed to runs; Start replaces it // with a cancelable context so Stop can abort in-flight runs, and until Start // it is context.Background(). runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord ctx context.Context // sched is the timing loop installed by Start; cancel tears down ctx on Stop. // Both are guarded by mu. sched *scheduler.Scheduler cancel context.CancelFunc // manager is the platform autostart implementation. It is nil in tests that // do not exercise autostart; Open() wires it via autostart.New(). manager autostart.Manager // 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. dispatchMu sync.Mutex observers []Observer } // 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, 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 { s := &Service{ store: store, jobs: jobs, runtimes: domain.NewRuntimes(jobs), schedules: make(map[int]domain.Schedule, len(jobs)), runJob: runner.RunJob, ctx: context.Background(), } // Parse every schedule once, then compute each job's first next-run so the // Service is ready to schedule the moment it exists — mirroring the old // scheduler's reset-on-construction. No lock is needed: construction is // single-threaded, before Start launches the timing loop. now := time.Now() for index := range s.jobs { job := &s.jobs[index] s.parseScheduleLocked(job) s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now) } return s } // Start begins scheduling with the real wall clock. It is the production entry // point; tests should call StartWith and supply a fake clock instead. Start is // expected once, during setup, before any concurrent use. func (s *Service) Start() { s.StartWith(scheduler.NewRealClock()) } // StartWith begins scheduling driven by the given clock; every tick calls // RunDue. Used by tests to inject a fake clock. func (s *Service) StartWith(clock scheduler.Clock) { s.mu.Lock() ctx, cancel := context.WithCancel(context.Background()) s.ctx = ctx s.cancel = cancel s.sched = scheduler.NewScheduler(clock, s.RunDue) sched := s.sched s.mu.Unlock() sched.Start() } // Stop halts scheduling and cancels the run context so in-flight runs see a // canceled context. It is safe to call when Start was never called. func (s *Service) Stop() { s.mu.Lock() sched := s.sched cancel := s.cancel s.mu.Unlock() if sched != nil { sched.Stop() } if cancel != nil { cancel() } } // Open loads the store and constructs a Service from it in one step. It is the // convenience entry point for the application; tests inject a pre-built store // via NewService instead. func Open() (*Service, error) { store, jobs, err := storage.OpenStore() if err != nil { return nil, err } svc := NewService(store, jobs) svc.manager = autostart.New() return svc, nil } // Store returns the underlying store. It is exposed so callers that still need // resolved paths and config (the GUI, during the transition) can reach them; // later phases narrow this surface. func (s *Service) Store() *storage.Store { return s.store } // Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers // from mutating Service-owned state behind its back: the Service stays the sole // writer. func (s *Service) Jobs() []domain.Job { s.mu.Lock() defer s.mu.Unlock() jobs := make([]domain.Job, len(s.jobs)) copy(jobs, s.jobs) return jobs } // Runtime returns the transient runtime state for a job ID, or nil if no job // with that ID is loaded. The returned pointer is the live runtime; reads of it // are only safe while no concurrent mutation is in flight. The scheduler now // drives the Service rather than sharing state, so the remaining concurrent // reader is the UI listener, which T4.1 marshals onto the main thread. func (s *Service) Runtime(id int) *domain.JobRuntime { s.mu.Lock() defer s.mu.Unlock() return s.runtimes[id] }