Refactoring complete: v0.4.0 architectural milestone (#1)
## Summary Completed Phase 5 refactoring and reached the target architecture. **Architectural milestone achieved:** - Service layer owns all state and is the sole writer - UI is a thin Fyne view, all widget updates marshaled via `fyne.Do` - Core engines are stateless and injectable - Domain types are pure (no `yaml:"-"` fields) - Full module builds and `go vet ./...` clean ## Changes - Bump version: 0.3.6 → 0.4.0 - Update CHANGELOG with Phase 5 summary - Add ROADMAP "Refactoring Follow-Ups" section ## Known follow-up work 1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag 2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines) See ROADMAP.md for details. --------- Co-authored-by: mixeme <mix.public@ya.ru> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package app
|
||||
|
||||
import "gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
// Event is something the Service did to its state that observers may want to
|
||||
// react to. It is a sealed interface: the concrete types in this file are the
|
||||
// only implementations (enforced by the unexported isEvent marker), so a UI
|
||||
// listener can exhaustively type-switch over them and the compiler will flag a
|
||||
// new event type that a switch forgot to handle.
|
||||
//
|
||||
// Events replace the old single onChange callback. Instead of the scheduler
|
||||
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
||||
// the UI's listener becomes the one place that touches widgets.
|
||||
type Event interface {
|
||||
isEvent()
|
||||
}
|
||||
|
||||
// JobChanged signals that a job's durable config or transient runtime changed:
|
||||
// created, edited, deleted, enabled/disabled, or a status transition such as a
|
||||
// run starting. Observers should re-read the affected state through the Service
|
||||
// (Jobs/Runtime) rather than expect a payload snapshot — that keeps the event
|
||||
// small and avoids handing out stale copies.
|
||||
//
|
||||
// JobID identifies the affected job. A zero JobID means a broad change (for
|
||||
// example a delete, or a global pause that touched every job) and observers
|
||||
// should refresh their whole view.
|
||||
type JobChanged struct {
|
||||
JobID int
|
||||
}
|
||||
|
||||
// RunRecorded signals that a job run finished and produced a RunRecord. It
|
||||
// carries the record by value because the record is an immutable result that
|
||||
// observers append to history; there is nothing for them to re-read.
|
||||
type RunRecorded struct {
|
||||
Record domain.RunRecord
|
||||
}
|
||||
|
||||
// SchedulerStateChanged signals that the global scheduler pause state flipped.
|
||||
// The UI uses it to update the pause/resume control and status text.
|
||||
type SchedulerStateChanged struct {
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// ErrorOccurred signals a background error that could not be returned to a
|
||||
// caller — typically a failed save or cleanup after an async run. The UI
|
||||
// surfaces it in the History tab so the user is not silently left with
|
||||
// un-persisted state.
|
||||
type ErrorOccurred struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (JobChanged) isEvent() {}
|
||||
func (RunRecorded) isEvent() {}
|
||||
func (SchedulerStateChanged) isEvent() {}
|
||||
func (ErrorOccurred) isEvent() {}
|
||||
|
||||
// Observer receives events emitted by the Service. OnEvent is the single
|
||||
// reaction point; the UI implements it and marshals any widget work onto the
|
||||
// main thread (fyne.Do) itself — the Service knows nothing about Fyne.
|
||||
type Observer interface {
|
||||
OnEvent(Event)
|
||||
}
|
||||
|
||||
// ObserverFunc adapts a plain function to the Observer interface, so callers can
|
||||
// subscribe a closure without declaring a type.
|
||||
type ObserverFunc func(Event)
|
||||
|
||||
// OnEvent calls the wrapped function.
|
||||
func (f ObserverFunc) OnEvent(event Event) { f(event) }
|
||||
|
||||
// Subscribe registers an observer to receive every subsequently emitted event.
|
||||
// Registration is expected during setup, before the scheduler starts, but is
|
||||
// guarded so it is safe at any time.
|
||||
func (s *Service) Subscribe(observer Observer) {
|
||||
s.dispatchMu.Lock()
|
||||
defer s.dispatchMu.Unlock()
|
||||
s.observers = append(s.observers, observer)
|
||||
}
|
||||
|
||||
// emit delivers an event to every registered observer.
|
||||
//
|
||||
// Single-threaded dispatch contract:
|
||||
// - emit holds dispatchMu for the whole dispatch, so observers are never
|
||||
// invoked concurrently and never overlap with each other or with Subscribe.
|
||||
// Each observer sees events one at a time, in emit order.
|
||||
// - emit must be called WITHOUT holding s.mu. The Service computes a state
|
||||
// change under mu, releases it, then emits — so an observer is free to call
|
||||
// back into read methods (Jobs/Runtime) without deadlocking on the state
|
||||
// lock.
|
||||
// - An observer must NOT call back into a Service method that emits (directly
|
||||
// or indirectly): dispatchMu is non-reentrant, so re-entrant emission would
|
||||
// deadlock. Observers react and return quickly; long or UI work is the
|
||||
// observer's own responsibility to defer (e.g. fyne.Do).
|
||||
func (s *Service) emit(event Event) {
|
||||
s.dispatchMu.Lock()
|
||||
defer s.dispatchMu.Unlock()
|
||||
for _, observer := range s.observers {
|
||||
observer.OnEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
|
||||
svc := newTestService(nil)
|
||||
|
||||
var first, second []Event
|
||||
svc.Subscribe(ObserverFunc(func(e Event) { first = append(first, e) }))
|
||||
svc.Subscribe(ObserverFunc(func(e Event) { second = append(second, e) }))
|
||||
|
||||
svc.emit(JobChanged{JobID: 7})
|
||||
svc.emit(RunRecorded{Record: domain.RunRecord{JobID: 7, State: "Success"}})
|
||||
svc.emit(SchedulerStateChanged{Paused: true})
|
||||
|
||||
for name, got := range map[string][]Event{"first": first, "second": second} {
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%s observer got %d events, want 3", name, len(got))
|
||||
}
|
||||
if jc, ok := got[0].(JobChanged); !ok || jc.JobID != 7 {
|
||||
t.Errorf("%s event[0] = %#v, want JobChanged{JobID:7}", name, got[0])
|
||||
}
|
||||
if rr, ok := got[1].(RunRecorded); !ok || rr.Record.State != "Success" {
|
||||
t.Errorf("%s event[1] = %#v, want RunRecorded Success", name, got[1])
|
||||
}
|
||||
if ss, ok := got[2].(SchedulerStateChanged); !ok || !ss.Paused {
|
||||
t.Errorf("%s event[2] = %#v, want SchedulerStateChanged{Paused:true}", name, got[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWithNoObserversIsNoop(t *testing.T) {
|
||||
svc := newTestService(nil)
|
||||
// Must not panic with an empty observer list.
|
||||
svc.emit(JobChanged{})
|
||||
}
|
||||
|
||||
// Observers may read Service state from within OnEvent without deadlocking,
|
||||
// because emit is called outside the state lock.
|
||||
func TestObserverCanReadServiceState(t *testing.T) {
|
||||
jobs := []domain.Job{{ID: 1, Name: "Job", Enabled: true}}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
var sawName string
|
||||
svc.Subscribe(ObserverFunc(func(Event) {
|
||||
if snapshot := svc.Jobs(); len(snapshot) == 1 {
|
||||
sawName = snapshot[0].Name
|
||||
}
|
||||
}))
|
||||
|
||||
svc.emit(JobChanged{JobID: 1})
|
||||
if sawName != "Job" {
|
||||
t.Errorf("observer read name = %q, want %q", sawName, "Job")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
// StatusText formats a job's current state for display: "Paused" if disabled,
|
||||
// else its runtime LastState (Ready, Running, Success, etc).
|
||||
func StatusText(j domain.Job, runtime *domain.JobRuntime) string {
|
||||
if !j.Enabled {
|
||||
return "Paused"
|
||||
}
|
||||
if runtime == nil {
|
||||
return ""
|
||||
}
|
||||
return runtime.LastState
|
||||
}
|
||||
|
||||
// EventText formats a run record for the History table, showing time, trigger,
|
||||
// job name, outcome state, detail, and log file (if any).
|
||||
func EventText(e domain.RunRecord) string {
|
||||
trigger := e.Trigger
|
||||
if trigger == "" {
|
||||
trigger = "Unknown"
|
||||
}
|
||||
if e.LogFile != "" {
|
||||
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile)
|
||||
}
|
||||
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
|
||||
}
|
||||
|
||||
// DisplayFolder formats a job's folder for display: "(No folder)" if empty,
|
||||
// else the trimmed folder name.
|
||||
func DisplayFolder(folder string) string {
|
||||
if strings.TrimSpace(folder) == "" {
|
||||
return "(No folder)"
|
||||
}
|
||||
return strings.TrimSpace(folder)
|
||||
}
|
||||
|
||||
// DisplayArguments formats a job's arguments for display: "(none)" if empty,
|
||||
// else the trimmed arguments.
|
||||
func DisplayArguments(arguments string) string {
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.TrimSpace(arguments)
|
||||
}
|
||||
|
||||
// DisplaySuccessExitCodes formats a job's success exit codes for display:
|
||||
// "0" (the default) if empty, else the trimmed codes.
|
||||
func DisplaySuccessExitCodes(codes string) string {
|
||||
if strings.TrimSpace(codes) == "" {
|
||||
return "0"
|
||||
}
|
||||
return strings.TrimSpace(codes)
|
||||
}
|
||||
|
||||
// DisplayRunMode formats a job's execution mode: "Start only" or
|
||||
// "Wait for completion".
|
||||
func DisplayRunMode(job domain.Job) string {
|
||||
if job.StartOnly {
|
||||
return "Start only"
|
||||
}
|
||||
return "Wait for completion"
|
||||
}
|
||||
|
||||
// DisplayInvocation formats a job's command and arguments for the jobs list,
|
||||
// joining them with spacing and collapsing newlines in arguments to spaces.
|
||||
func DisplayInvocation(job domain.Job) string {
|
||||
if strings.TrimSpace(job.Arguments) == "" {
|
||||
return job.Command
|
||||
}
|
||||
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
||||
}
|
||||
|
||||
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
||||
// or 0 if not found.
|
||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||
for display, index := range indexes {
|
||||
if index == jobIndex {
|
||||
return display
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestStatusText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
job domain.Job
|
||||
runtime *domain.JobRuntime
|
||||
want string
|
||||
}{
|
||||
{"disabled is paused", domain.Job{Enabled: false}, &domain.JobRuntime{LastState: "Running"}, "Paused"},
|
||||
{"enabled shows runtime state", domain.Job{Enabled: true}, &domain.JobRuntime{LastState: "Success"}, "Success"},
|
||||
{"enabled with nil runtime is empty", domain.Job{Enabled: true}, nil, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := StatusText(tc.job, tc.runtime); got != tc.want {
|
||||
t.Errorf("StatusText = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventText(t *testing.T) {
|
||||
withLog := domain.RunRecord{
|
||||
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
|
||||
State: "Success", Detail: "ok", LogFile: "build.log",
|
||||
}
|
||||
if got, want := EventText(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build.log"; got != want {
|
||||
t.Errorf("EventText with log = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
noLog := domain.RunRecord{
|
||||
Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build",
|
||||
State: "Success", Detail: "ok",
|
||||
}
|
||||
if got, want := EventText(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want {
|
||||
t.Errorf("EventText without log = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// An empty trigger is shown as "Unknown".
|
||||
blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d"}
|
||||
if got, want := EventText(blank), "t Unknown J S d"; got != want {
|
||||
t.Errorf("EventText blank trigger = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayFolder(t *testing.T) {
|
||||
if got := DisplayFolder(" "); got != "(No folder)" {
|
||||
t.Errorf("blank folder = %q, want %q", got, "(No folder)")
|
||||
}
|
||||
if got := DisplayFolder(" Reports "); got != "Reports" {
|
||||
t.Errorf("folder = %q, want %q", got, "Reports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayArguments(t *testing.T) {
|
||||
if got := DisplayArguments(""); got != "(none)" {
|
||||
t.Errorf("empty args = %q, want %q", got, "(none)")
|
||||
}
|
||||
if got := DisplayArguments(" -v "); got != "-v" {
|
||||
t.Errorf("args = %q, want %q", got, "-v")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplaySuccessExitCodes(t *testing.T) {
|
||||
if got := DisplaySuccessExitCodes(" "); got != "0" {
|
||||
t.Errorf("empty codes = %q, want %q", got, "0")
|
||||
}
|
||||
if got := DisplaySuccessExitCodes(" 0,1 "); got != "0,1" {
|
||||
t.Errorf("codes = %q, want %q", got, "0,1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayRunMode(t *testing.T) {
|
||||
if got := DisplayRunMode(domain.Job{StartOnly: true}); got != "Start only" {
|
||||
t.Errorf("start-only = %q, want %q", got, "Start only")
|
||||
}
|
||||
if got := DisplayRunMode(domain.Job{StartOnly: false}); got != "Wait for completion" {
|
||||
t.Errorf("wait = %q, want %q", got, "Wait for completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayInvocation(t *testing.T) {
|
||||
if got := DisplayInvocation(domain.Job{Command: "echo"}); got != "echo" {
|
||||
t.Errorf("no args = %q, want %q", got, "echo")
|
||||
}
|
||||
// Arguments are appended with spacing and their newlines collapsed to spaces.
|
||||
job := domain.Job{Command: "echo", Arguments: " hi\nthere "}
|
||||
if got, want := DisplayInvocation(job), "echo hi there"; got != want {
|
||||
t.Errorf("with args = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayIndex(t *testing.T) {
|
||||
indexes := []int{4, 7, 2}
|
||||
if got := DisplayIndex(indexes, 7); got != 1 {
|
||||
t.Errorf("DisplayIndex(7) = %d, want 1", got)
|
||||
}
|
||||
// A jobIndex not present returns 0.
|
||||
if got := DisplayIndex(indexes, 99); got != 0 {
|
||||
t.Errorf("DisplayIndex(missing) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
)
|
||||
|
||||
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
||||
// lives in the log files on disk; this is only the recent activity shown in the
|
||||
// GUI, so an old run aging out of the list is intentional.
|
||||
const maxJobLogs = 50
|
||||
|
||||
// timestampLayout matches the format used for run records so UI-action activity
|
||||
// and command runs line up in the History view.
|
||||
const timestampLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// errJobNotFound is returned by the mutating operations when no loaded job has
|
||||
// the requested ID.
|
||||
var errJobNotFound = errors.New("job not found")
|
||||
|
||||
// CreateJob normalizes and validates the supplied configuration, assigns the
|
||||
// next free ID, and adds it to the loaded set. It returns the stored job (with
|
||||
// its assigned ID) so the caller can select it. The job is persisted and a
|
||||
// "Created" activity record is emitted.
|
||||
func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
||||
normalizeJob(&job)
|
||||
if err := validateJob(job); err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
job.ID = s.nextIDLocked()
|
||||
s.jobs = append(s.jobs, job)
|
||||
runtime := domain.NewRuntime(job)
|
||||
s.runtimes[job.ID] = runtime
|
||||
s.parseScheduleLocked(&job)
|
||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: job.ID})
|
||||
return job, err
|
||||
}
|
||||
|
||||
// UpdateJob replaces the durable configuration of the job with the same ID,
|
||||
// keeping its runtime state (keyed by ID) and recomputing its next run. The job
|
||||
// is persisted and an "Updated" activity record is emitted.
|
||||
func (s *Service) UpdateJob(job domain.Job) error {
|
||||
normalizeJob(&job)
|
||||
if err := validateJob(job); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
existing := s.findByIDLocked(job.ID)
|
||||
if existing == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("update job %d: %w", job.ID, errJobNotFound)
|
||||
}
|
||||
*existing = job
|
||||
runtime := s.runtimeForLocked(existing)
|
||||
// An edit may have toggled Enabled; reflect that into the status the same way
|
||||
// a dedicated enable/disable would, then recompute the next run.
|
||||
if job.Enabled {
|
||||
if runtime.LastState == "" || runtime.LastState == "Paused" {
|
||||
runtime.LastState = "Ready"
|
||||
}
|
||||
} else {
|
||||
runtime.LastState = "Paused"
|
||||
}
|
||||
s.parseScheduleLocked(existing)
|
||||
s.refreshNextRunLocked(existing, runtime)
|
||||
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: job.ID})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteJob removes the job with the given ID along with its runtime and cached
|
||||
// schedule. The remaining jobs are persisted and a "Deleted" activity record is
|
||||
// emitted. The JobChanged event carries a zero ID to signal a broad change.
|
||||
func (s *Service) DeleteJob(id int) error {
|
||||
s.mu.Lock()
|
||||
index := s.indexByIDLocked(id)
|
||||
if index < 0 {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("delete job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
deleted := s.jobs[index]
|
||||
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
|
||||
delete(s.runtimes, id)
|
||||
delete(s.schedules, id)
|
||||
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: 0})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
|
||||
// and recomputes its next run (respecting the global pause); disabling parks it
|
||||
// at "Paused". The job is persisted and a "Resumed"/"Paused" activity record is
|
||||
// emitted.
|
||||
func (s *Service) SetEnabled(id int, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
job := s.findByIDLocked(id)
|
||||
if job == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("set enabled job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
job.Enabled = enabled
|
||||
runtime := s.runtimeForLocked(job)
|
||||
s.parseScheduleLocked(job)
|
||||
|
||||
var record domain.RunRecord
|
||||
if enabled {
|
||||
runtime.LastState = "Ready"
|
||||
s.refreshNextRunLocked(job, runtime)
|
||||
record = uiRecord(id, job.Name, "Resumed", "Job was enabled")
|
||||
} else {
|
||||
runtime.LastState = "Paused"
|
||||
runtime.NextRun = "Paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
||||
}
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetGlobalPause flips the global pause that gates all execution, scheduled and
|
||||
// manual. Each enabled job's next-run text reflects the new state immediately so
|
||||
// the list view is understandable before the next tick. A "Paused"/"Resumed"
|
||||
// scheduler activity record and a SchedulerStateChanged event are emitted.
|
||||
func (s *Service) SetGlobalPause(paused bool) error {
|
||||
s.mu.Lock()
|
||||
s.paused = paused
|
||||
now := time.Now()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
runtime := s.runtimeForLocked(job)
|
||||
s.refreshNextRunFromLocked(job, runtime, now)
|
||||
}
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
state, detail := "Resumed", "All job execution resumed"
|
||||
if paused {
|
||||
state, detail = "Paused", "All job execution paused"
|
||||
}
|
||||
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
||||
s.emit(SchedulerStateChanged{Paused: paused})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunNow starts a manual run of a job. It refuses to run while globally paused —
|
||||
// the pause is an emergency stop for all execution — and will not start a job
|
||||
// that is already running. The run itself happens on a background goroutine that
|
||||
// records the result through the Service, so RunNow returns as soon as the run
|
||||
// is started. The error reports why a run could not be started (or a failure to
|
||||
// persist the "Running" status), not the run's own outcome.
|
||||
func (s *Service) RunNow(id int) error {
|
||||
s.mu.Lock()
|
||||
if s.paused {
|
||||
s.mu.Unlock()
|
||||
return errors.New("scheduler is paused")
|
||||
}
|
||||
job := s.findByIDLocked(id)
|
||||
if job == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("run job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
runtime := s.runtimeForLocked(job)
|
||||
if runtime.LastState == "Running" {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("job %d is already running", id)
|
||||
}
|
||||
err := s.startRunLocked(job, runtime, "Manual")
|
||||
s.mu.Unlock()
|
||||
|
||||
// Reflect the "Running" transition; the run's completion emits again later.
|
||||
s.emit(JobChanged{JobID: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
|
||||
// the given time. It is a no-op while globally paused. At most one job is started
|
||||
// per call so scheduled shell commands in this single process do not overlap; a
|
||||
// job already running is skipped. Run results are recorded back through the
|
||||
// Service, so the Service stays the sole writer of job and runtime state. The
|
||||
// time is supplied by the scheduler's clock, which lets tests drive
|
||||
// due-evaluation deterministically.
|
||||
func (s *Service) RunDue(now time.Time) {
|
||||
s.mu.Lock()
|
||||
var startedID int
|
||||
var startErr error
|
||||
if !s.paused {
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
runtime := s.runtimeForLocked(job)
|
||||
if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) {
|
||||
continue
|
||||
}
|
||||
if runtime.LastState == "Running" {
|
||||
continue
|
||||
}
|
||||
startErr = s.startRunLocked(job, runtime, "Schedule")
|
||||
startedID = job.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if startErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
|
||||
}
|
||||
if startedID != 0 {
|
||||
s.emit(JobChanged{JobID: startedID})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSettings validates and persists a new application configuration. The
|
||||
// loaded jobs are re-saved because the jobs directory may have changed, and log
|
||||
// cleanup runs so a tightened retention policy takes effect immediately.
|
||||
func (s *Service) UpdateSettings(config domain.Config) error {
|
||||
if err := validateConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.store.Config = config
|
||||
if err := s.store.SaveConfig(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
||||
// the (possibly new) jobs directory and cleanup targets the new logs dir.
|
||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
logsDir := s.store.Paths.LogsDir
|
||||
maxFiles := s.store.Config.MaxLogFiles
|
||||
maxAge := s.store.Config.MaxLogAgeDays
|
||||
s.mu.Unlock()
|
||||
|
||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||
}
|
||||
|
||||
// startRunLocked transitions a job to "Running", persists that, and launches the
|
||||
// run on a background goroutine. The caller must hold mu.
|
||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string) error {
|
||||
jobCopy := *job
|
||||
runtime.LastState = "Running"
|
||||
runtime.NextRun = "Running"
|
||||
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||
runtime.NextDue = time.Time{}
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||
// from under the goroutine after we release mu.
|
||||
go s.executeRun(s.ctx, jobCopy, trigger)
|
||||
return err
|
||||
}
|
||||
|
||||
// executeRun runs the job off the lock, then records the result back through the
|
||||
// Service under the lock and announces it. It runs on its own goroutine.
|
||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) {
|
||||
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||
|
||||
s.mu.Lock()
|
||||
var cleanupErr, saveErr error
|
||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||
runtime := s.runtimeForLocked(current)
|
||||
runtime.LastRun = record.Time
|
||||
runtime.LastState = record.State
|
||||
runtime.Output = record.Output
|
||||
prependLog(runtime, record)
|
||||
s.refreshNextRunLocked(current, runtime)
|
||||
cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
saveErr = s.store.SaveJobs(s.jobs)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if cleanupErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
||||
}
|
||||
if saveErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: jobCopy.ID})
|
||||
}
|
||||
|
||||
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
||||
// honoring enabled/paused state. The caller must hold mu.
|
||||
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
||||
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
||||
}
|
||||
|
||||
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
||||
// time, used when one timestamp should drive a whole batch (e.g. a global
|
||||
// pause). The caller must hold mu.
|
||||
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||
if !job.Enabled {
|
||||
runtime.NextRun = "Paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
if s.paused {
|
||||
runtime.NextRun = "Scheduler paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
s.prepareNextRunLocked(job, runtime, from)
|
||||
}
|
||||
|
||||
// prepareNextRunLocked computes the concrete next-due time from the cached
|
||||
// schedule. A missing cache entry means the schedule string was unparseable.
|
||||
// The caller must hold mu.
|
||||
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||
sched, ok := s.schedules[job.ID]
|
||||
if !ok {
|
||||
runtime.NextRun = "Invalid schedule"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
runtime.NextDue = sched.Next(from)
|
||||
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
||||
}
|
||||
|
||||
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
||||
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
||||
// two apart. The caller must hold mu.
|
||||
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
||||
sched, err := domain.Parse(job.Schedule)
|
||||
if err != nil {
|
||||
delete(s.schedules, job.ID)
|
||||
return
|
||||
}
|
||||
s.schedules[job.ID] = sched
|
||||
}
|
||||
|
||||
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
||||
// given ID, or nil. The caller must hold mu.
|
||||
func (s *Service) findByIDLocked(id int) *domain.Job {
|
||||
index := s.indexByIDLocked(id)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.jobs[index]
|
||||
}
|
||||
|
||||
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
||||
// The caller must hold mu.
|
||||
func (s *Service) indexByIDLocked(id int) int {
|
||||
for index := range s.jobs {
|
||||
if s.jobs[index].ID == id {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
||||
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
||||
func (s *Service) runtimeForLocked(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
|
||||
}
|
||||
|
||||
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
||||
// caller must hold mu.
|
||||
func (s *Service) nextIDLocked() int {
|
||||
next := 1
|
||||
for index := range s.jobs {
|
||||
if s.jobs[index].ID >= next {
|
||||
next = s.jobs[index].ID + 1
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// prependLog adds a record to the front of a runtime's activity list and caps
|
||||
// its length so it cannot grow without bound.
|
||||
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
||||
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
||||
if len(runtime.Logs) > maxJobLogs {
|
||||
runtime.Logs = runtime.Logs[:maxJobLogs]
|
||||
}
|
||||
}
|
||||
|
||||
// uiRecord builds an activity record for a user/Service action, using the same
|
||||
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
||||
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
||||
return domain.RunRecord{
|
||||
Time: time.Now().Format(timestampLayout),
|
||||
JobID: jobID,
|
||||
JobName: jobName,
|
||||
Trigger: "UI",
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// runningOutput is the placeholder output shown while a job is running, before
|
||||
// the real command output replaces it.
|
||||
func runningOutput(job domain.Job, trigger string, started time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
builder.WriteString("Running since " + started.Format(timestampLayout) + "\n\n")
|
||||
builder.WriteString("trigger:\n")
|
||||
builder.WriteString(trigger + "\n\n")
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(runner.LogArguments(job.Arguments))
|
||||
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
||||
builder.WriteString(runner.SuccessExitCodesText(job))
|
||||
builder.WriteString("\n\nstart_only:\n")
|
||||
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// normalizeJob trims user-entered fields and applies the same defaults the job
|
||||
// dialog used, so callers do not have to.
|
||||
func normalizeJob(job *domain.Job) {
|
||||
job.Name = strings.TrimSpace(job.Name)
|
||||
job.Folder = strings.TrimSpace(job.Folder)
|
||||
job.Schedule = strings.TrimSpace(job.Schedule)
|
||||
job.Command = strings.TrimSpace(job.Command)
|
||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
||||
if job.SuccessExitCodes == "" {
|
||||
job.SuccessExitCodes = "0"
|
||||
}
|
||||
}
|
||||
|
||||
// validateJob enforces the minimum executable definition: name, schedule, and
|
||||
// command must be present. Folder is optional. The schedule string itself is not
|
||||
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
||||
// next-run, matching the prior behavior.
|
||||
func validateJob(job domain.Job) error {
|
||||
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
||||
return errors.New("name, schedule, and command are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfig rejects settings that would break persistence or cleanup.
|
||||
func validateConfig(config domain.Config) error {
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
return errors.New("jobs directory is required")
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
return errors.New("logs directory is required")
|
||||
}
|
||||
if config.MaxLogFiles <= 0 {
|
||||
return errors.New("max log files must be a positive number")
|
||||
}
|
||||
if config.MaxLogAgeDays <= 0 {
|
||||
return errors.New("max log age days must be a positive number")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// newTempService builds a Service backed by a store rooted in a temp directory,
|
||||
// so the mutating operations can persist to real (throwaway) files.
|
||||
func newTempService(t *testing.T, jobs []domain.Job) *Service {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := &storage.Store{
|
||||
Paths: storage.Paths{
|
||||
ExecutablePath: filepath.Join(dir, "gosentry"),
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, "gosentry.yaml"),
|
||||
JobsDir: dir,
|
||||
JobsPath: filepath.Join(dir, "jobs.yaml"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
},
|
||||
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30},
|
||||
}
|
||||
return NewService(store, jobs)
|
||||
}
|
||||
|
||||
// recorder is a test observer that captures every emitted event.
|
||||
type recorder struct {
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (r *recorder) OnEvent(e Event) { r.events = append(r.events, e) }
|
||||
|
||||
func (r *recorder) jobChanged() (ids []int) {
|
||||
for _, e := range r.events {
|
||||
if jc, ok := e.(JobChanged); ok {
|
||||
ids = append(ids, jc.JobID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *recorder) records() (out []domain.RunRecord) {
|
||||
for _, e := range r.events {
|
||||
if rr, ok := e.(RunRecorded); ok {
|
||||
out = append(out, rr.Record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateJobAssignsIDAndEmits(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
created, err := svc.CreateJob(domain.Job{Name: "Build", Schedule: "@every 1m", Command: "echo hi", Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
if created.ID != 1 {
|
||||
t.Errorf("first job ID = %d, want 1", created.ID)
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 1 || got[0].Name != "Build" {
|
||||
t.Fatalf("jobs after create = %+v", got)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt == nil || rt.LastState != "Ready" {
|
||||
t.Errorf("runtime = %+v, want LastState Ready", rt)
|
||||
}
|
||||
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Created" {
|
||||
t.Errorf("records = %+v, want one Created", recs)
|
||||
}
|
||||
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 1 {
|
||||
t.Errorf("JobChanged ids = %v, want [1]", ids)
|
||||
}
|
||||
|
||||
// A second job takes the next free ID.
|
||||
second, err := svc.CreateJob(domain.Job{Name: "Two", Schedule: "@every 1m", Command: "echo two"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob 2: %v", err)
|
||||
}
|
||||
if second.ID != 2 {
|
||||
t.Errorf("second job ID = %d, want 2", second.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateJobValidates(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if _, err := svc.CreateJob(domain.Job{Schedule: "@every 1m", Command: "echo"}); err == nil {
|
||||
t.Error("expected error for missing name")
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 0 {
|
||||
t.Errorf("invalid job should not be stored, jobs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "New", Schedule: "@every 1m", Command: "echo", Enabled: false}); err != nil {
|
||||
t.Fatalf("UpdateJob: %v", err)
|
||||
}
|
||||
got := svc.Jobs()
|
||||
if got[0].Name != "New" || got[0].Enabled {
|
||||
t.Errorf("job after update = %+v", got[0])
|
||||
}
|
||||
if rt := svc.Runtime(5); rt == nil || rt.LastState != "Paused" || rt.NextRun != "Paused" {
|
||||
t.Errorf("runtime after disable = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobReenablesPausedJob(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}})
|
||||
if rt := svc.Runtime(5); rt.LastState != "Paused" {
|
||||
t.Fatalf("precondition: runtime = %+v, want Paused", rt)
|
||||
}
|
||||
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil {
|
||||
t.Fatalf("UpdateJob: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() {
|
||||
t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeForLocked lazily recreates a missing runtime entry so the Service stays
|
||||
// robust if a job somehow lacks one. Dropping the entry and driving an operation
|
||||
// that needs it exercises that path.
|
||||
func TestRuntimeLazilyRecreated(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
svc.mu.Lock()
|
||||
delete(svc.runtimes, 1)
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.SetEnabled(1, true); err != nil {
|
||||
t.Fatalf("SetEnabled: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt == nil {
|
||||
t.Error("runtime was not lazily recreated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteJobRemovesEverything(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
if err := svc.DeleteJob(1); err != nil {
|
||||
t.Fatalf("DeleteJob: %v", err)
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 0 {
|
||||
t.Errorf("jobs after delete = %+v", got)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt != nil {
|
||||
t.Errorf("runtime should be gone, got %+v", rt)
|
||||
}
|
||||
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Deleted" {
|
||||
t.Errorf("records = %+v, want one Deleted", recs)
|
||||
}
|
||||
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 0 {
|
||||
t.Errorf("JobChanged ids = %v, want [0] (broad)", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteJobNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.DeleteJob(42); err == nil {
|
||||
t.Error("expected not-found error deleting unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.SetEnabled(42, true); err == nil {
|
||||
t.Error("expected not-found error enabling unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledToggles(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}})
|
||||
|
||||
if err := svc.SetEnabled(1, true); err != nil {
|
||||
t.Fatalf("SetEnabled true: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Ready" || rt.NextDue.IsZero() {
|
||||
t.Errorf("enabled runtime = %+v, want Ready with a next-due", rt)
|
||||
}
|
||||
if err := svc.SetEnabled(1, false); err != nil {
|
||||
t.Fatalf("SetEnabled false: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Paused" || !rt.NextDue.IsZero() {
|
||||
t.Errorf("disabled runtime = %+v, want Paused with no next-due", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{
|
||||
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
|
||||
{ID: 2, Name: "Off", Schedule: "@every 1m", Command: "echo", Enabled: false},
|
||||
})
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.NextRun != "Scheduler paused" {
|
||||
t.Errorf("enabled job next-run = %q, want %q", rt.NextRun, "Scheduler paused")
|
||||
}
|
||||
if rt := svc.Runtime(2); rt.NextRun != "Paused" {
|
||||
t.Errorf("disabled job next-run = %q, want %q", rt.NextRun, "Paused")
|
||||
}
|
||||
var sawState bool
|
||||
for _, e := range rec.events {
|
||||
if ss, ok := e.(SchedulerStateChanged); ok && ss.Paused {
|
||||
sawState = true
|
||||
}
|
||||
}
|
||||
if !sawState {
|
||||
t.Error("expected a SchedulerStateChanged{Paused:true} event")
|
||||
}
|
||||
|
||||
// Resuming recomputes a real next run for the enabled job.
|
||||
if err := svc.SetGlobalPause(false); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.NextDue.IsZero() {
|
||||
t.Errorf("resumed enabled job should have a next-due, got %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowUsesRunnerAndRecords(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan domain.RunRecord, 1)
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
||||
if trigger != "Manual" {
|
||||
t.Errorf("trigger = %q, want Manual", trigger)
|
||||
}
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
|
||||
}
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
|
||||
select {
|
||||
case done <- rr.Record:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if err := svc.RunNow(1); err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case record := <-done:
|
||||
if record.State != "Success" {
|
||||
t.Errorf("recorded state = %q, want Success", record.State)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for run to be recorded")
|
||||
}
|
||||
|
||||
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
|
||||
t.Errorf("runtime after run = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.RunNow(99); err == nil {
|
||||
t.Error("expected not-found error for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
// Park the job in the "Running" state so a second RunNow must refuse: the
|
||||
// runner signals once it has started and then blocks until released.
|
||||
entered := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
var calls int32
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}
|
||||
}
|
||||
done := make(chan struct{}, 1)
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" {
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if err := svc.RunNow(1); err != nil {
|
||||
t.Fatalf("first RunNow: %v", err)
|
||||
}
|
||||
<-entered // the run is now in-flight and blocked
|
||||
|
||||
if err := svc.RunNow(1); err == nil {
|
||||
t.Error("expected RunNow to be refused while already running")
|
||||
}
|
||||
close(release)
|
||||
|
||||
// Wait for the in-flight run to finish before returning so its background
|
||||
// writes complete before t.TempDir cleanup removes the directory.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for the in-flight run to complete")
|
||||
}
|
||||
|
||||
// Only the first run should ever have reached the runner.
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("runner called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowRefusedWhilePaused(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran bool
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
ran = true
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
if err := svc.RunNow(1); err == nil {
|
||||
t.Error("expected RunNow to be refused while paused")
|
||||
}
|
||||
if ran {
|
||||
t.Error("runner must not be invoked while paused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueStartsDueJob(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan domain.RunRecord, 1)
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
||||
if trigger != "Schedule" {
|
||||
t.Errorf("trigger = %q, want Schedule", trigger)
|
||||
}
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
|
||||
}
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
|
||||
select {
|
||||
case done <- rr.Record:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// The job's next-due was primed ~1m ahead at construction; tick well past it.
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RunDue did not start the due job")
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
|
||||
t.Errorf("runtime after scheduled run = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&ran, 1)
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
|
||||
// Next-due is ~1m out, so nothing is due "now".
|
||||
svc.RunDue(time.Now())
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if atomic.LoadInt32(&ran) != 0 {
|
||||
t.Error("RunDue ran a job before it was due")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueSkipsJobInRunningState verifies that RunDue will not start a second
|
||||
// concurrent instance of a job that is already in "Running" state — even if the
|
||||
// job's NextDue is in the past. This guards against the window between
|
||||
// executeRun completing and refreshNextRunLocked setting a new NextDue.
|
||||
func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
var calls int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return domain.RunRecord{State: "Success"}
|
||||
}
|
||||
|
||||
// Force the job into "Running" with a past NextDue, simulating an in-flight
|
||||
// run. We set NextDue to a past time so the due check would otherwise pass.
|
||||
svc.mu.Lock()
|
||||
rt := svc.runtimes[1]
|
||||
rt.LastState = "Running"
|
||||
rt.NextDue = time.Now().Add(-time.Minute)
|
||||
svc.mu.Unlock()
|
||||
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 0 {
|
||||
t.Errorf("RunDue called runner %d time(s) for a job in Running state, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&ran, 1)
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if atomic.LoadInt32(&ran) != 0 {
|
||||
t.Error("RunDue ran a job while globally paused")
|
||||
}
|
||||
}
|
||||
|
||||
// appFakeClock is a scheduler.Clock whose tick and "now" the test controls, used
|
||||
// to verify Start wires the loop to RunDue without the wall clock.
|
||||
type appFakeClock struct {
|
||||
ticks chan time.Time
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *appFakeClock) Now() time.Time { return c.now }
|
||||
func (c *appFakeClock) Ticks() <-chan time.Time { return c.ticks }
|
||||
func (c *appFakeClock) Stop() {}
|
||||
|
||||
func TestStartDrivesRunDueOnTick(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return domain.RunRecord{State: "Success"}
|
||||
}
|
||||
|
||||
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
|
||||
svc.StartWith(clock)
|
||||
defer svc.Stop()
|
||||
|
||||
clock.ticks <- clock.now
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Start did not drive a run from a clock tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
|
||||
bad := svc.store.Config
|
||||
bad.MaxLogFiles = 0
|
||||
if err := svc.UpdateSettings(bad); err == nil {
|
||||
t.Error("expected validation error for non-positive max log files")
|
||||
}
|
||||
|
||||
good := svc.store.Config
|
||||
good.NotifyOnFailure = false
|
||||
good.MaxLogAgeDays = 7
|
||||
if err := svc.UpdateSettings(good); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
|
||||
t.Errorf("config not applied: %+v", svc.Store().Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
base := svc.store.Config
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(c *domain.Config)
|
||||
}{
|
||||
{"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
|
||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := base
|
||||
tc.mutate(&cfg)
|
||||
if err := svc.UpdateSettings(cfg); err == nil {
|
||||
t.Errorf("expected validation error for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||
runtime := &domain.JobRuntime{}
|
||||
for i := 0; i < maxJobLogs+10; i++ {
|
||||
prependLog(runtime, domain.RunRecord{Detail: "r"})
|
||||
}
|
||||
if len(runtime.Logs) != maxJobLogs {
|
||||
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||
)
|
||||
|
||||
// InstallDesktopIcon installs the application's .desktop file and icon on
|
||||
// Linux (no-op on other platforms). The resulting icon path is stored in
|
||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
||||
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
||||
s.store.Paths.DesktopIcon = iconPath
|
||||
}
|
||||
}
|
||||
|
||||
// AutostartStatus reports whether the platform autostart entry matches the
|
||||
// current StartOnLogin setting in the stored config.
|
||||
func (s *Service) AutostartStatus() (ok bool, message string) {
|
||||
s.mu.Lock()
|
||||
enabled := s.store.Config.StartOnLogin
|
||||
execPath := s.store.Paths.ExecutablePath
|
||||
manager := s.manager
|
||||
s.mu.Unlock()
|
||||
if manager == nil {
|
||||
return false, "autostart not available"
|
||||
}
|
||||
return manager.Status(enabled, execPath)
|
||||
}
|
||||
|
||||
// ApplyAutostart writes or removes the platform autostart entry to match the
|
||||
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
|
||||
func (s *Service) ApplyAutostart() error {
|
||||
s.mu.Lock()
|
||||
enabled := s.store.Config.StartOnLogin
|
||||
execPath := s.store.Paths.ExecutablePath
|
||||
iconPath := s.store.Paths.DesktopIcon
|
||||
manager := s.manager
|
||||
s.mu.Unlock()
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return manager.Set(enabled, execPath, iconPath)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
func newTestService(jobs []domain.Job) *Service {
|
||||
return NewService(&storage.Store{}, jobs)
|
||||
}
|
||||
|
||||
func TestNewServiceBuildsRuntimePerJob(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{ID: 1, Name: "Enabled", Enabled: true},
|
||||
{ID: 2, Name: "Disabled", Enabled: false},
|
||||
}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
if got := svc.Runtime(1); got == nil {
|
||||
t.Fatal("expected runtime for enabled job 1")
|
||||
} else if got.LastState != "Ready" {
|
||||
t.Errorf("enabled job runtime state = %q, want %q", got.LastState, "Ready")
|
||||
}
|
||||
if got := svc.Runtime(2); got == nil {
|
||||
t.Fatal("expected runtime for disabled job 2")
|
||||
} else if got.LastState != "Paused" {
|
||||
t.Errorf("disabled job runtime state = %q, want %q", got.LastState, "Paused")
|
||||
}
|
||||
if got := svc.Runtime(99); got != nil {
|
||||
t.Errorf("expected nil runtime for unknown job, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsReturnsCopy(t *testing.T) {
|
||||
jobs := []domain.Job{{ID: 1, Name: "Original"}}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
snapshot := svc.Jobs()
|
||||
if len(snapshot) != 1 {
|
||||
t.Fatalf("Jobs() len = %d, want 1", len(snapshot))
|
||||
}
|
||||
// Mutating the returned slice must not affect Service-owned state.
|
||||
snapshot[0].Name = "Mutated"
|
||||
if again := svc.Jobs(); again[0].Name != "Original" {
|
||||
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreReturnsWiredStore(t *testing.T) {
|
||||
store := &storage.Store{}
|
||||
svc := NewService(store, nil)
|
||||
if svc.Store() != store {
|
||||
t.Error("Store() did not return the wired store")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package app
|
||||
|
||||
// Version is the application version shown in the GUI and used by build
|
||||
// scripts in artifact names. It is a var rather than a const so release builds
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "0.4.0"
|
||||
Reference in New Issue
Block a user