721b049100
The Jobs directory row named a folder and assumed the file inside it was called jobs.json. It is now a Jobs file row: Browse opens a file picker filtered to .json, the field stays editable so a file that does not exist yet can be typed, and the job list can live under any name. Config.JobsDir/jobs_dir becomes Config.JobsFile/jobs_file, holding the whole path; Paths.JobsDir is derived from it so saves still create the folder. An older gosentry.json is migrated on load by joining its jobs_dir with jobs.json — the exact file that version used — and the retired key is dropped when the config is rewritten. The default clears before unmarshalling, or a file that omits jobs_file and a file that sets it would be indistinguishable and the migration would never run. Saving used to write the current job list over whatever was at the new path, which made switching to an existing jobs file impossible: its contents were destroyed. An existing file now wins. Its jobs are loaded, normalized, and adopted, with runtimes, schedule cache, next-run times and log-seeded statistics rebuilt around them by adoptJobsLocked — the same helper NewService now uses, so construction and adoption cannot drift. A path with no file behind it still receives the current jobs, which is how the file is renamed or relocated. The new file is read before anything is written, so an unparsable one leaves both the config and the jobs untouched. Adoption drops every runtime, and a run finishing afterwards would write its result onto whichever job inherited its ID, so the switch is refused while a job is running. Unrelated settings still save during a run. Because the replacement happens without a prompt, the Service emits JobsLoaded with the path and count, and History carries the receipt. A path that names only a folder (trailing separator, a dot, or two dots) is rejected with a validation error instead of failing later with an opaque OS error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
16 KiB
Go
491 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
|
)
|
|
|
|
// 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)
|
|
if err != nil {
|
|
s.jobs = s.jobs[:len(s.jobs)-1]
|
|
delete(s.runtimes, job.ID)
|
|
delete(s.schedules, job.ID)
|
|
s.mu.Unlock()
|
|
return domain.Job{}, err
|
|
}
|
|
s.mu.Unlock()
|
|
s.emit(RunRecorded{Record: record})
|
|
s.emit(JobChanged{JobID: job.ID})
|
|
return job, nil
|
|
}
|
|
|
|
// 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()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.emit(RunRecorded{Record: record})
|
|
s.emit(JobChanged{JobID: job.ID})
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.emit(RunRecorded{Record: record})
|
|
s.emit(JobChanged{JobID: 0})
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.emit(RunRecorded{Record: record})
|
|
s.emit(JobChanged{JobID: id})
|
|
return nil
|
|
}
|
|
|
|
// SetGlobalPause flips the global pause that gates scheduled execution.
|
|
// Manual "Run now" remains available while paused. 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
|
|
s.store.Config.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.SaveConfig()
|
|
if err == nil {
|
|
err = s.store.SaveJobs(s.jobs)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 nil
|
|
}
|
|
|
|
// SetJobListView persists the Jobs list density preference. Unlike
|
|
// SetGlobalPause this touches nothing but the config: no job changed, so there
|
|
// is no SaveJobs, and no event is emitted — the choice is presentational and the
|
|
// Jobs view refreshes its own list, whereas an event would trigger a pointless
|
|
// whole-window refresh. Anything that is not "compact" is stored as detailed so
|
|
// the file never gains an unrecognised value.
|
|
func (s *Service) SetJobListView(view domain.JobListView) error {
|
|
if !view.IsCompact() {
|
|
view = domain.JobListViewDetailed
|
|
}
|
|
s.mu.Lock()
|
|
if s.store.Config.JobListView == view {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.store.Config.JobListView = view
|
|
err := s.store.SaveConfig()
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
|
// notifications for failed job runs. It reads the config under mu so it is
|
|
// safe to call from any goroutine.
|
|
func (s *Service) ShouldNotifyOnFailure() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.store.Config.NotifyOnFailure
|
|
}
|
|
|
|
// UpdateSettings validates and persists a new application configuration. The
|
|
// loaded jobs are re-saved because the jobs file may have changed, and log
|
|
// cleanup runs so a tightened retention policy takes effect immediately.
|
|
//
|
|
// Pointing the config at a different jobs file that already exists adopts that
|
|
// file: its jobs replace the loaded ones, which is the only way the user can
|
|
// switch between job lists. A path with no file there yet receives the current
|
|
// jobs instead, which is how the jobs file is renamed or relocated. Adoption
|
|
// discards all runtime state, so it is refused while a job is running.
|
|
func (s *Service) UpdateSettings(config domain.Config) error {
|
|
if err := validateConfig(config); err != nil {
|
|
return err
|
|
}
|
|
// The path is stored exactly as it is resolved, so a hand-typed value with
|
|
// stray spaces cannot make the saved setting and the file in use disagree.
|
|
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
|
|
|
s.mu.Lock()
|
|
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
|
|
switching := jobsPath != s.store.Paths.JobsPath
|
|
if switching && s.anyRunningLocked() {
|
|
s.mu.Unlock()
|
|
return errors.New("cannot change the jobs file while a job is running")
|
|
}
|
|
// Read the new file before anything is written, so a file that cannot be
|
|
// parsed leaves both the config and the current jobs untouched.
|
|
var adopted []domain.Job
|
|
if switching {
|
|
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
|
if err != nil {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
|
}
|
|
if found {
|
|
adopted = jobs
|
|
}
|
|
}
|
|
|
|
s.store.Config = config
|
|
if err := s.store.SaveConfig(); err != nil {
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
if adopted != nil {
|
|
s.adoptJobsLocked(adopted)
|
|
}
|
|
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
|
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
|
|
// jobs are written back too, which persists the IDs and defaults that
|
|
// normalization filled in, exactly as loading them at startup would.
|
|
if err := s.store.SaveJobs(s.jobs); err != nil {
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
loaded := len(s.jobs)
|
|
logsDir := s.store.Paths.LogsDir
|
|
maxFiles := s.store.Config.MaxLogFiles
|
|
maxAge := s.store.Config.MaxLogAgeDays
|
|
s.mu.Unlock()
|
|
|
|
if adopted != nil {
|
|
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
|
// History which file those jobs came from, since nothing was asked.
|
|
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
|
s.emit(JobChanged{})
|
|
}
|
|
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
policy := strings.TrimSpace(job.OverlapPolicy)
|
|
if policy != "" && policy != string(domain.OverlapPolicySkip) && policy != string(domain.OverlapPolicyQueue) {
|
|
return errors.New("overlap policy must be 'skip', 'queue', or empty")
|
|
}
|
|
if job.TimeoutSeconds != nil && *job.TimeoutSeconds < 0 {
|
|
return errors.New("timeout must be zero (no timeout) or a positive number of seconds, or unset to inherit the global default")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// hasFileName reports whether a path ends in something that can be a file name.
|
|
// It is a syntax check only — an existing directory whose name looks like a file
|
|
// name still passes, and fails at write time — but it catches the shapes a user
|
|
// types when they mean a folder: a trailing separator, "." and "..".
|
|
func hasFileName(path string) bool {
|
|
if strings.HasSuffix(path, "/") || strings.HasSuffix(path, string(filepath.Separator)) {
|
|
return false
|
|
}
|
|
switch filepath.Base(path) {
|
|
case ".", "..", string(filepath.Separator):
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// validateConfig rejects settings that would break persistence or cleanup.
|
|
func validateConfig(config domain.Config) error {
|
|
jobsFile := strings.TrimSpace(config.JobsFile)
|
|
if jobsFile == "" {
|
|
return errors.New("jobs file is required")
|
|
}
|
|
// A path that names only a folder would be written to as if it were a file
|
|
// and fail later with an opaque OS error, so require a file name here.
|
|
if !hasFileName(jobsFile) {
|
|
return errors.New("jobs file must include a file name")
|
|
}
|
|
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")
|
|
}
|
|
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
|
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
|
}
|
|
if config.OverlapPolicy != domain.OverlapPolicySkip && config.OverlapPolicy != domain.OverlapPolicyQueue {
|
|
return errors.New("overlap policy must be 'skip' or 'queue'")
|
|
}
|
|
if config.DefaultTimeoutSeconds < 0 {
|
|
return errors.New("default timeout must not be negative (0 means no timeout)")
|
|
}
|
|
// Empty Theme is accepted and normalized to the default on load, so older
|
|
// configs (and hand-built ones) stay valid without an explicit theme.
|
|
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
|
|
return errors.New("theme must be 'default' or 'gosentry'")
|
|
}
|
|
return nil
|
|
}
|