Files
gosentry/src/app/operations.go
T
mixeme 0e6b3bcecf refactor: split source files that exceeded the ~300-line ceiling
Mechanical moves only — operations, store, history_view, and settings_view
are now split along their existing seams so every file stays within the 250+20%
guideline. Document the new layout in ARCHITECTURE.md and close the ROADMAP item.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:22:08 +03:00

172 lines
5.4 KiB
Go

package app
import (
"errors"
"fmt"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// 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)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); err != nil {
// The write is atomic, so a failure left the file holding the previous
// list: take the job back out so memory matches what is on disk. Another
// operation may have run in between, so it is removed by ID rather than by
// truncating the slice.
s.mu.Lock()
if index := s.indexByIDLocked(job.ID); index >= 0 {
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
}
delete(s.runtimes, job.ID)
delete(s.schedules, job.ID)
s.mu.Unlock()
return domain.Job{}, err
}
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)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); 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")
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); 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{}
// A disabled job's own occurrences stop firing, so a "queue" backlog it was
// carrying no longer corresponds to anything: clear it rather than replaying
// stale deferred runs if the job is re-enabled later.
runtime.PendingRuns = 0
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
s.mu.Unlock()
if err := save(); err != nil {
return err
}
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: id})
return nil
}