Files
gosentry/src/app/run.go
T
mixeme d381a22034 P3.2: Move RunDue/RunNow/startRunLocked/executeRun into app/run.go
Extracts the four dispatch functions and their private helper
runningOutput into a dedicated file so operations.go stays focused on
CRUD and settings. No behaviour change; shared helpers (prependLog,
refreshNextRunLocked, etc.) remain in operations.go where other
operations already call them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:39:54 +03:00

140 lines
4.7 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner"
)
// 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})
}
}
// 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})
}
// 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\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}