fix: harden run persistence and error surfacing from code review
Snapshot store paths under lock before async runs, roll back failed start/save state, emit UI events only after successful persistence, surface log write failures, and sync stale YAML docs to JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -81,8 +81,9 @@ flowchart LR
|
|||||||
|
|
||||||
4. Manual run:
|
4. Manual run:
|
||||||
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
|
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
|
||||||
exists, is not already running, and that the scheduler is not globally paused,
|
exists, is not already running, and (in sequential mode) that no other job is
|
||||||
then executes `runner.RunJob` with the `Manual` trigger.
|
running, then executes `runner.RunJob` with the `Manual` trigger. Manual runs
|
||||||
|
are allowed even while the scheduler is globally paused.
|
||||||
|
|
||||||
5. Command execution:
|
5. Command execution:
|
||||||
`runner.RunJob` builds the platform-specific invocation, executes the
|
`runner.RunJob` builds the platform-specific invocation, executes the
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# GoSentry — Code Review (2026-06-29)
|
||||||
|
|
||||||
|
Версия на момент ревью: **0.11.2**
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
| Критерий | Оценка |
|
||||||
|
|----------|--------|
|
||||||
|
| Архитектура | 9/10 |
|
||||||
|
| Сложность vs масштаб | 8/10 |
|
||||||
|
| Качество кода | 8/10 |
|
||||||
|
| Поддерживаемость | 8/10 |
|
||||||
|
| Логические ошибки | 8/10 (после исправлений) |
|
||||||
|
|
||||||
|
Проект зрелый и поддерживаемый для десктопного планировщика (~59 `.go`-файлов). Архитектура слоистая, core-логика хорошо протестирована.
|
||||||
|
|
||||||
|
## Сильные стороны
|
||||||
|
|
||||||
|
- Single-writer `app.Service` с явным locking contract
|
||||||
|
- Разделение `domain.Job` (durable) и `domain.JobRuntime` (transient)
|
||||||
|
- Event-driven UI без обратных вызовов в Fyne под lock
|
||||||
|
- Portable storage от `os.Executable()`
|
||||||
|
- Инъекция `runJob` и `scheduler.Clock` в тестах
|
||||||
|
- Подробная документация (`ARCHITECTURE.md`, inline comments)
|
||||||
|
|
||||||
|
## Найденные проблемы и статус исправлений
|
||||||
|
|
||||||
|
| # | Проблема | Серьёзность | Статус |
|
||||||
|
|---|----------|-------------|--------|
|
||||||
|
| 1 | Data race: `store.Paths` в `executeRun` без lock | Высокая | Исправлено |
|
||||||
|
| 2 | Run стартует при ошибке `SaveJobs` | Средняя | Исправлено |
|
||||||
|
| 3 | CRUD эмитит events при failed save | Средняя | Исправлено |
|
||||||
|
| 4 | Overlap queue — только один `Pending` | Средняя | Документировано (by design) |
|
||||||
|
| 5 | `time.Now()` vs scheduler clock в `startRunLocked` | Низкая | Исправлено |
|
||||||
|
| 6 | Silent log write failures | Низкая | Исправлено |
|
||||||
|
| 7 | Невалидный per-job `overlap_policy` | Низкая | Исправлено |
|
||||||
|
| 8 | Docs drift (YAML, RunNow/pause) | Низкая | Исправлено |
|
||||||
|
|
||||||
|
## Намеренное поведение (не баги)
|
||||||
|
|
||||||
|
- `RunNow` разрешён при global pause и для disabled jobs
|
||||||
|
- Sequential mode — FIFO по порядку в `jobs.json`
|
||||||
|
- Scheduler tick 1s — sub-second `@every` не поддерживается
|
||||||
|
- Command timeout 30s — глобальный лимит
|
||||||
|
|
||||||
|
## Рекомендации на будущее
|
||||||
|
|
||||||
|
- UI widget tests или smoke E2E
|
||||||
|
- Per-job command timeout в конфиге
|
||||||
|
- Счётчик вместо `Pending bool` для overlap queue (если нужна полная очередь)
|
||||||
|
- Убрать legacy ticket-ссылки (T3.1) из комментариев
|
||||||
+1
-3
@@ -170,15 +170,13 @@ Tests display-formatting helpers used by the UI.
|
|||||||
|
|
||||||
**Package:** `storage`
|
**Package:** `storage`
|
||||||
|
|
||||||
Tests JSON round-tripping, YAML migration import, and default generation.
|
Tests JSON round-tripping and default generation.
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
||||||
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
||||||
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
||||||
| `TestLoadOrCreateConfigMigratesFromLegacy` | Verifies that when `gosentry.json` is absent but `gosentry.yaml` exists the config is imported from the legacy YAML file on first load. |
|
|
||||||
| `TestLoadOrCreateJobsMigratesFromLegacy` | Verifies that when `jobs.json` is absent but `jobs.yaml` exists the jobs are imported from the legacy YAML file on first load. |
|
|
||||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
|
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
|
||||||
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
||||||
|
|
||||||
|
|||||||
+33
-10
@@ -42,11 +42,17 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
|||||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
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.mu.Unlock()
|
||||||
|
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: job.ID})
|
s.emit(JobChanged{JobID: job.ID})
|
||||||
return job, err
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateJob replaces the durable configuration of the job with the same ID,
|
// UpdateJob replaces the durable configuration of the job with the same ID,
|
||||||
@@ -82,9 +88,12 @@ func (s *Service) UpdateJob(job domain.Job) error {
|
|||||||
err := s.store.SaveJobs(s.jobs)
|
err := s.store.SaveJobs(s.jobs)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: job.ID})
|
s.emit(JobChanged{JobID: job.ID})
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteJob removes the job with the given ID along with its runtime and cached
|
// DeleteJob removes the job with the given ID along with its runtime and cached
|
||||||
@@ -105,9 +114,12 @@ func (s *Service) DeleteJob(id int) error {
|
|||||||
err := s.store.SaveJobs(s.jobs)
|
err := s.store.SaveJobs(s.jobs)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: 0})
|
s.emit(JobChanged{JobID: 0})
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
|
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
|
||||||
@@ -140,15 +152,19 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
|||||||
err := s.store.SaveJobs(s.jobs)
|
err := s.store.SaveJobs(s.jobs)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetGlobalPause flips the global pause that gates all execution, scheduled and
|
// SetGlobalPause flips the global pause that gates scheduled execution.
|
||||||
// manual. Each enabled job's next-run text reflects the new state immediately so
|
// Manual "Run now" remains available while paused. Each enabled job's next-run
|
||||||
// the list view is understandable before the next tick. A "Paused"/"Resumed"
|
// text reflects the new state immediately so the list view is understandable
|
||||||
// scheduler activity record and a SchedulerStateChanged event are emitted.
|
// before the next tick. A "Paused"/"Resumed" scheduler activity record and a
|
||||||
|
// SchedulerStateChanged event are emitted.
|
||||||
func (s *Service) SetGlobalPause(paused bool) error {
|
func (s *Service) SetGlobalPause(paused bool) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.paused = paused
|
s.paused = paused
|
||||||
@@ -165,13 +181,16 @@ func (s *Service) SetGlobalPause(paused bool) error {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
if paused {
|
if paused {
|
||||||
state, detail = "Paused", "All job execution paused"
|
state, detail = "Paused", "All job execution paused"
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
||||||
s.emit(SchedulerStateChanged{Paused: paused})
|
s.emit(SchedulerStateChanged{Paused: paused})
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||||
@@ -344,6 +363,10 @@ func validateJob(job domain.Job) error {
|
|||||||
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
||||||
return errors.New("name, schedule, and command are required")
|
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")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-18
@@ -100,6 +100,9 @@ func TestCreateJobValidates(t *testing.T) {
|
|||||||
if got := svc.Jobs(); len(got) != 0 {
|
if got := svc.Jobs(); len(got) != 0 {
|
||||||
t.Errorf("invalid job should not be stored, jobs = %+v", got)
|
t.Errorf("invalid job should not be stored, jobs = %+v", got)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
|
||||||
|
t.Error("expected error for invalid overlap policy")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
||||||
@@ -247,11 +250,11 @@ func TestRunNowUsesRunnerAndRecords(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
done := make(chan domain.RunRecord, 1)
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
|
||||||
if trigger != "Manual" {
|
if trigger != "Manual" {
|
||||||
t.Errorf("trigger = %q, want Manual", trigger)
|
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"}
|
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
|
||||||
}
|
}
|
||||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
|
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
|
||||||
@@ -295,11 +298,11 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
entered := make(chan struct{}, 1)
|
entered := make(chan struct{}, 1)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- struct{}{}
|
entered <- struct{}{}
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||||
@@ -338,12 +341,12 @@ func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
|||||||
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
func TestRunNowAllowedWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
select {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
return domain.RunRecord{State: "Success"}
|
return domain.RunRecord{State: "Success"}, nil
|
||||||
}
|
}
|
||||||
if err := svc.SetGlobalPause(true); err != nil {
|
if err := svc.SetGlobalPause(true); err != nil {
|
||||||
t.Fatalf("SetGlobalPause: %v", err)
|
t.Fatalf("SetGlobalPause: %v", err)
|
||||||
@@ -363,11 +366,11 @@ func TestRunDueStartsDueJob(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan domain.RunRecord, 1)
|
done := make(chan domain.RunRecord, 1)
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) (domain.RunRecord, error) {
|
||||||
if trigger != "Schedule" {
|
if trigger != "Schedule" {
|
||||||
t.Errorf("trigger = %q, want Schedule", trigger)
|
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"}
|
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
|
||||||
}
|
}
|
||||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
|
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
|
||||||
@@ -394,9 +397,9 @@ func TestRunDueStartsDueJob(t *testing.T) {
|
|||||||
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
var ran int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}
|
return domain.RunRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next-due is ~1m out, so nothing is due "now".
|
// Next-due is ~1m out, so nothing is due "now".
|
||||||
@@ -415,9 +418,9 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
return domain.RunRecord{State: "Success"}
|
return domain.RunRecord{State: "Success"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force the job into "Running" with a past NextDue, simulating an in-flight
|
// Force the job into "Running" with a past NextDue, simulating an in-flight
|
||||||
@@ -439,9 +442,9 @@ func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
|||||||
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
var ran int32
|
var ran int32
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
return domain.RunRecord{}
|
return domain.RunRecord{}, nil
|
||||||
}
|
}
|
||||||
if err := svc.SetGlobalPause(true); err != nil {
|
if err := svc.SetGlobalPause(true); err != nil {
|
||||||
t.Fatalf("SetGlobalPause: %v", err)
|
t.Fatalf("SetGlobalPause: %v", err)
|
||||||
@@ -469,12 +472,12 @@ func TestStartDrivesRunDueOnTick(t *testing.T) {
|
|||||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||||
|
|
||||||
done := make(chan struct{}, 1)
|
done := make(chan struct{}, 1)
|
||||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
select {
|
select {
|
||||||
case done <- struct{}{}:
|
case done <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
return domain.RunRecord{State: "Success"}
|
return domain.RunRecord{State: "Success"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
|
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
|
||||||
@@ -593,13 +596,13 @@ func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
|
|||||||
|
|
||||||
var ran int32
|
var ran int32
|
||||||
runStarted := make(chan struct{}, 1)
|
runStarted := make(chan struct{}, 1)
|
||||||
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
svc2.runJob = func(context.Context, *domain.Job, string, string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&ran, 1)
|
atomic.AddInt32(&ran, 1)
|
||||||
select {
|
select {
|
||||||
case runStarted <- struct{}{}:
|
case runStarted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
return domain.RunRecord{}
|
return domain.RunRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunDue must not start any job while paused: the scheduler stays paused after
|
// RunDue must not start any job while paused: the scheduler stays paused after
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
||||||
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
s.store.Paths.DesktopIcon = iconPath
|
s.store.Paths.DesktopIcon = iconPath
|
||||||
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-12
@@ -36,11 +36,13 @@ func (s *Service) RunNow(id int) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return errors.New("another job is already running (sequential mode)")
|
return errors.New("another job is already running (sequential mode)")
|
||||||
}
|
}
|
||||||
err := s.startRunLocked(job, runtime, "Manual")
|
err := s.startRunLocked(job, runtime, "Manual", time.Now())
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
// Reflect the "Running" transition; the run's completion emits again later.
|
// Reflect the "Running" transition; the run's completion emits again later.
|
||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +88,9 @@ func (s *Service) RunDue(now time.Time) {
|
|||||||
// tick once the in-flight run has finished.
|
// tick once the in-flight run has finished.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.startRunLocked(job, runtime, "Schedule"); err != nil {
|
if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
|
||||||
startErr = err
|
startErr = err
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
started = append(started, job.ID)
|
started = append(started, job.ID)
|
||||||
running = true
|
running = true
|
||||||
@@ -103,22 +106,47 @@ func (s *Service) RunDue(now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runEnv snapshots path and retention settings for one background run so
|
||||||
|
// executeRun does not read store.Paths or store.Config without holding mu.
|
||||||
|
type runEnv struct {
|
||||||
|
logsDir string
|
||||||
|
maxFiles int
|
||||||
|
maxAge int
|
||||||
|
}
|
||||||
|
|
||||||
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
||||||
// scheduled occurrence, persists that, and launches the run on a background
|
// scheduled occurrence, persists that, and launches the run on a background
|
||||||
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
|
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
|
||||||
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
|
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
|
||||||
// firing during a long run and apply the overlap policy. The caller must hold mu.
|
// firing during a long run and apply the overlap policy. The caller must hold mu.
|
||||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string) error {
|
// now is the reference time for next-due advancement and the running placeholder.
|
||||||
|
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
|
||||||
jobCopy := *job
|
jobCopy := *job
|
||||||
|
prevState := runtime.LastState
|
||||||
|
prevNextRun := runtime.NextRun
|
||||||
|
prevOutput := runtime.Output
|
||||||
|
prevNextDue := runtime.NextDue
|
||||||
|
|
||||||
runtime.LastState = "Running"
|
runtime.LastState = "Running"
|
||||||
runtime.NextRun = "Running"
|
runtime.NextRun = "Running"
|
||||||
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
|
runtime.Output = runningOutput(jobCopy, trigger, now)
|
||||||
s.advanceNextDueLocked(job, runtime, time.Now())
|
s.advanceNextDueLocked(job, runtime, now)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
if err := s.store.SaveJobs(s.jobs); err != nil {
|
||||||
|
runtime.LastState = prevState
|
||||||
|
runtime.NextRun = prevNextRun
|
||||||
|
runtime.Output = prevOutput
|
||||||
|
runtime.NextDue = prevNextDue
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
env := runEnv{
|
||||||
|
logsDir: s.store.Paths.LogsDir,
|
||||||
|
maxFiles: s.store.Config.MaxLogFiles,
|
||||||
|
maxAge: s.store.Config.MaxLogAgeDays,
|
||||||
|
}
|
||||||
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||||
// from under the goroutine after we release mu.
|
// from under the goroutine after we release mu.
|
||||||
go s.executeRun(s.ctx, jobCopy, trigger)
|
go s.executeRun(s.ctx, jobCopy, trigger, env)
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRun runs the job off the lock, then records the result back through the
|
// executeRun runs the job off the lock, then records the result back through the
|
||||||
@@ -126,11 +154,12 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
|
|||||||
// running (the "queue" overlap policy), and it is still enabled and the scheduler
|
// running (the "queue" overlap policy), and it is still enabled and the scheduler
|
||||||
// is not paused, the deferred run is started immediately. It runs on its own
|
// is not paused, the deferred run is started immediately. It runs on its own
|
||||||
// goroutine.
|
// goroutine.
|
||||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) {
|
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
|
||||||
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
var cleanupErr, saveErr error
|
var cleanupErr, saveErr error
|
||||||
|
var rerunStarted bool
|
||||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||||
runtime := s.runtimeForLocked(current)
|
runtime := s.runtimeForLocked(current)
|
||||||
runtime.LastRun = record.Time
|
runtime.LastRun = record.Time
|
||||||
@@ -143,15 +172,19 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
|||||||
if rerun {
|
if rerun {
|
||||||
// A scheduled occurrence fired while this run was active under the
|
// A scheduled occurrence fired while this run was active under the
|
||||||
// "queue" policy; start that deferred run now.
|
// "queue" policy; start that deferred run now.
|
||||||
saveErr = s.startRunLocked(current, runtime, "Schedule")
|
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
|
||||||
|
rerunStarted = saveErr == nil
|
||||||
} else {
|
} else {
|
||||||
s.refreshNextRunLocked(current, runtime)
|
s.refreshNextRunLocked(current, runtime)
|
||||||
saveErr = s.store.SaveJobs(s.jobs)
|
saveErr = s.store.SaveJobs(s.jobs)
|
||||||
}
|
}
|
||||||
cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if logErr != nil {
|
||||||
|
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
||||||
|
}
|
||||||
if cleanupErr != nil {
|
if cleanupErr != nil {
|
||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
||||||
}
|
}
|
||||||
@@ -159,7 +192,9 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
|||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
|
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
|
if !rerunStarted {
|
||||||
s.emit(JobChanged{JobID: jobCopy.ID})
|
s.emit(JobChanged{JobID: jobCopy.ID})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveOverlapPolicy resolves the overlap policy that actually governs a
|
// effectiveOverlapPolicy resolves the overlap policy that actually governs a
|
||||||
|
|||||||
+16
-16
@@ -116,10 +116,10 @@ func TestRunDueParallelStartsAllDueJobs(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -157,10 +157,10 @@ func TestRunDueSequentialSerializes(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -194,11 +194,11 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -240,11 +240,11 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -297,11 +297,11 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -347,11 +347,11 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
|||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
var calls int32
|
var calls int32
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
atomic.AddInt32(&calls, 1)
|
atomic.AddInt32(&calls, 1)
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -395,10 +395,10 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
@@ -437,10 +437,10 @@ func TestRunNowSequentialGuard(t *testing.T) {
|
|||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||||
entered <- job.ID
|
entered <- job.ID
|
||||||
<-release
|
<-release
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||||
}
|
}
|
||||||
done := completions(svc)
|
done := completions(svc)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ type Service struct {
|
|||||||
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
// 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
|
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
||||||
// it is context.Background().
|
// it is context.Background().
|
||||||
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
|
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error)
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
|
||||||
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package domain
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// JobRuntime is the transient execution state for a Job. It is never written to
|
// JobRuntime is the transient execution state for a Job. It is never written to
|
||||||
// jobs.yaml: it is rebuilt from scratch each time GoSentry starts and is held in
|
// jobs.json: it is rebuilt from scratch each time GoSentry starts and is held in
|
||||||
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate
|
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate
|
||||||
// from Job is what lets the durable configuration file stay free of run records,
|
// from Job is what lets the durable configuration file stay free of run records,
|
||||||
// status strings, and scheduling bookkeeping.
|
// status strings, and scheduling bookkeeping.
|
||||||
@@ -20,7 +20,8 @@ type JobRuntime struct {
|
|||||||
NextDue time.Time
|
NextDue time.Time
|
||||||
|
|
||||||
// Pending is set when a run was skipped due to the overlap policy being
|
// Pending is set when a run was skipped due to the overlap policy being
|
||||||
// "queue". The scheduler will start this job as soon as the current run ends.
|
// "queue". At most one deferred run is remembered; executeRun starts it when
|
||||||
|
// the current run ends.
|
||||||
Pending bool
|
Pending bool
|
||||||
|
|
||||||
// Execution-time statistics accumulated since the last process start.
|
// Execution-time statistics accumulated since the last process start.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,12 +12,12 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) string {
|
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, durationMS int64, started time.Time) (string, error) {
|
||||||
if strings.TrimSpace(logsDir) == "" {
|
if strings.TrimSpace(logsDir) == "" {
|
||||||
return ""
|
return "", errors.New("logs directory is empty")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("create logs directory: %w", err)
|
||||||
}
|
}
|
||||||
// The timestamp comes first so a plain directory listing is naturally sorted
|
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||||
// by run time. The job name is included for human scanning, but sanitized to
|
// by run time. The job name is included for human scanning, but sanitized to
|
||||||
@@ -26,9 +27,9 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
|
|||||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
||||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return ""
|
return "", fmt.Errorf("write log file: %w", err)
|
||||||
}
|
}
|
||||||
return path
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeFileName(name string) string {
|
func sanitizeFileName(name string) string {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
const commandTimeout = 30 * time.Second
|
const commandTimeout = 30 * time.Second
|
||||||
const commandWaitDelay = 2 * time.Second
|
const commandWaitDelay = 2 * time.Second
|
||||||
|
|
||||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
|
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) (domain.RunRecord, error) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
// Commands can hang forever if a script waits for input or a child process
|
// Commands can hang forever if a script waits for input or a child process
|
||||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||||
@@ -54,7 +54,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timestamp := now.Format("2006-01-02 15:04:05")
|
timestamp := now.Format("2006-01-02 15:04:05")
|
||||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
|
logFile, logErr := writeRunLog(logsDir, *job, trigger, state, detail, output, durationMS, now)
|
||||||
|
|
||||||
// The runner is now pure with respect to the job: it returns a RunRecord and
|
// The runner is now pure with respect to the job: it returns a RunRecord and
|
||||||
// lets the caller fold that record into the job's JobRuntime. Run state no
|
// lets the caller fold that record into the job's JobRuntime. Run state no
|
||||||
@@ -69,7 +69,7 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
LogFile: logFile,
|
LogFile: logFile,
|
||||||
Output: output,
|
Output: output,
|
||||||
DurationMS: durationMS,
|
DurationMS: durationMS,
|
||||||
}
|
}, logErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ func TestRunJobLogFileAllHeaders(t *testing.T) {
|
|||||||
Command: echoCommand("header test output"),
|
Command: echoCommand("header test output"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Schedule", logsDir)
|
record, err := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.LogFile == "" {
|
if record.LogFile == "" {
|
||||||
t.Fatal("expected log file to be written")
|
t.Fatal("expected log file to be written")
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,10 @@ func TestRunJobRecordFields(t *testing.T) {
|
|||||||
Command: echoCommand("record field check"),
|
Command: echoCommand("record field check"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
if record.JobID != job.ID {
|
if record.JobID != job.ID {
|
||||||
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
||||||
@@ -158,7 +164,10 @@ func TestRunJobWritesLogFile(t *testing.T) {
|
|||||||
Command: echoCommand("hello from test"),
|
Command: echoCommand("hello from test"),
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.LogFile == "" {
|
if record.LogFile == "" {
|
||||||
t.Fatal("expected log file path")
|
t.Fatal("expected log file path")
|
||||||
}
|
}
|
||||||
@@ -193,7 +202,10 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
|||||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "OK" {
|
if record.State != "OK" {
|
||||||
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||||
}
|
}
|
||||||
@@ -222,7 +234,10 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
|||||||
Command: scriptPath,
|
Command: scriptPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "OK" {
|
if record.State != "OK" {
|
||||||
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||||
}
|
}
|
||||||
@@ -244,7 +259,10 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
|||||||
Arguments: "/C\necho separate arguments ok",
|
Arguments: "/C\necho separate arguments ok",
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
record, err := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "OK" {
|
if record.State != "OK" {
|
||||||
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
|
||||||
}
|
}
|
||||||
@@ -267,7 +285,10 @@ func TestRunJobFailsOnNonZeroExitCode(t *testing.T) {
|
|||||||
job.Arguments = "/C\nexit /b 1"
|
job.Arguments = "/C\nexit /b 1"
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "Failed" {
|
if record.State != "Failed" {
|
||||||
t.Fatalf("expected non-zero exit code to fail, got state %q detail %q", record.State, record.Detail)
|
t.Fatalf("expected non-zero exit code to fail, got state %q detail %q", record.State, record.Detail)
|
||||||
}
|
}
|
||||||
@@ -291,7 +312,10 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
StartOnly: true,
|
StartOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "OK" {
|
if record.State != "OK" {
|
||||||
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
|
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
|
||||||
}
|
}
|
||||||
@@ -312,7 +336,10 @@ func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
|||||||
StartOnly: true,
|
StartOnly: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
record, err := RunJob(context.Background(), &job, "Manual", t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if record.State != "Failed" {
|
if record.State != "Failed" {
|
||||||
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
|
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
|||||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||||
var events []event
|
var events []event
|
||||||
for _, current := range jobs {
|
for _, current := range jobs {
|
||||||
// At startup this is usually empty because jobs.yaml does not persist
|
// At startup this is usually empty because jobs.json does not persist
|
||||||
// runtime logs. The function still centralizes the merge for future
|
// runtime logs. The function still centralizes the merge for future
|
||||||
// history loading from log metadata.
|
// history loading from log metadata.
|
||||||
if rt := runtimes[current.ID]; rt != nil {
|
if rt := runtimes[current.ID]; rt != nil {
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
|||||||
history, refreshHistory := newHistoryView(&events)
|
history, refreshHistory := newHistoryView(&events)
|
||||||
recordStartup := func(duration time.Duration, windowShown bool) {
|
recordStartup := func(duration time.Duration, windowShown bool) {
|
||||||
// Startup is recorded as an in-memory History event instead of being
|
// Startup is recorded as an in-memory History event instead of being
|
||||||
// persisted into jobs.yaml. It is session diagnostics, not durable job
|
// persisted into jobs.json. It is session diagnostics, not durable job
|
||||||
// state, and keeping it ephemeral avoids polluting the human-editable YAML
|
// state, and keeping it ephemeral avoids polluting the human-editable JSON
|
||||||
// file with process-lifetime bookkeeping.
|
// file with process-lifetime bookkeeping.
|
||||||
detail := "Window shown in " + duration.Round(time.Millisecond).String()
|
detail := "Window shown in " + duration.Round(time.Millisecond).String()
|
||||||
if !windowShown {
|
if !windowShown {
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// gap between them instead of merging into one block.
|
// gap between them instead of merging into one block.
|
||||||
container.NewVBox(
|
container.NewVBox(
|
||||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)),
|
||||||
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||||
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||||
settingsRow("Max log files", maxLogFiles),
|
settingsRow("Max log files", maxLogFiles),
|
||||||
|
|||||||
Reference in New Issue
Block a user