Compare commits

30 Commits

Author SHA1 Message Date
mixeme 6754d0405b Merge branch 'docs/refactoring-plan' of https://gitea.mixdep.ru/mix/gosentry into docs/refactoring-plan 2026-06-19 08:34:08 +03:00
mixeme 6073d5e112 T3.6: Add src/app unit tests (no Fyne)
Fill the test gaps in the app package, raising coverage from 82% to 97%:

- format_test.go: cover all display helpers moved in T3.5 (StatusText,
  EventText, Display*), previously untested.
- operations_test.go: add RunNow not-found and refused-while-running
  paths, DeleteJob/SetEnabled not-found errors, UpdateJob re-enable,
  runtimeForLocked lazy-recreate, UpdateSettings invalid-config branches,
  and prependLog activity-list capping.

All tests are Fyne-free, using a temp-dir store, a fake runner seam, and
a fake Clock. go vet and go test -race pass across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:31:04 +03:00
mixeme cf2af1d464 @
T3.6: Add src/app unit tests (no Fyne)

Fill the test gaps in the app package, raising coverage from 82% to 97%:

- format_test.go: cover all display helpers moved in T3.5 (StatusText,
  EventText, Display*), previously untested.
- operations_test.go: add RunNow not-found and refused-while-running
  paths, DeleteJob/SetEnabled not-found errors, UpdateJob re-enable,
  runtimeForLocked lazy-recreate, UpdateSettings invalid-config branches,
  and prependLog activity-list capping.

All tests are Fyne-free, using a temp-dir store, a fake runner seam, and
a fake Clock. go vet and go test -race pass across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-06-19 08:30:42 +03:00
mixeme 6e5c42fbed T3.5: Move display helpers to src/app/format.go
Extract pure display/formatting functions from the GUI:
- StatusText, EventText, DisplayFolder, DisplayArguments,
  DisplaySuccessExitCodes, DisplayRunMode, DisplayInvocation, DisplayIndex.

These have no Fyne dependencies and serve as a shared formatting layer for
the service to use (e.g. StatusText in the tests when mocking the GUI).
The GUI imports them as app.DisplayFolder(...) etc.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-19 08:25:37 +03:00
mixeme a4c93a5122 T3.4: Convert scheduler to drive app.Service; inject Clock
The scheduler no longer shares a *[]domain.Job with the GUI. It is now a
thin timing loop with an injected Clock that calls a tick callback; the
application service is the sole writer of job and runtime state.

- scheduler: add Clock interface + RealClock (clock.go); strip all job
  logic from scheduler.go (NewScheduler(clock, tick)); rewrite tests to
  cover the loop with a fake clock.
- app.Service: add RunDue(now) (pause + one-run-per-tick policy, records
  back through the service) and Start(Clock)/Stop() owning a cancelable
  run context; prime each job's first next-run at construction. Capture
  the run context under the lock for executeRun.
- gui: talk only to app.Service (no shared state) — Open() the service,
  keep a refreshed snapshot, route every mutation through the service,
  and react to changes via a single Subscribe listener.
- Tests: add RunDue (due/not-due/paused) and Start-drives-RunDue cases.

Verified with CGO + MSYS2 UCRT64: go vet ./... clean, go test -race
./... green (GUI included), full module builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 08:22:35 +03:00
mixeme d8ab9acf7e T3.3: Add state-mutating operations to app.Service
Add src/app/operations.go with the seven intents that make the Service
the sole writer of job and runtime state: CreateJob, UpdateJob,
DeleteJob, SetEnabled, RunNow, SetGlobalPause, UpdateSettings. Each
returns error, persists through the store, and announces changes via
RunRecorded/JobChanged/SchedulerStateChanged events.

Extend the Service with a parsed-schedule cache, a global paused flag,
an injectable runJob seam (defaults to runner.RunJob) for testing the
run-now path, and a lifecycle ctx. Run and next-run timing now live in
the Service (duplicating the scheduler temporarily); T3.4 converts the
scheduler to drive the Service and removes the duplication.

Autostart is left to the caller until T5.2's injectable Manager; async
save errors in the run goroutine remain deferred to T5.1. Adds 12 tests
covering create/update/delete, enable/pause, global pause, run-now with
a fake runner, and settings persistence/validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:48:41 +03:00
mixeme 5e51381b7a T3.2: Add app event types and observer dispatch
Add src/app/events.go: a sealed Event interface with three concrete
types (JobChanged, RunRecorded, SchedulerStateChanged), an Observer
interface plus ObserverFunc adapter, and Subscribe/emit on the Service.
This replaces the scheduler's single onChange callback with typed
events the UI can exhaustively type-switch over.

Dispatch is serialized by a dedicated dispatchMu (separate from the
state lock): observers never run concurrently, emit must be called
without holding s.mu so observers can read Service state, and observers
must not re-enter an emitting method. emit is wired to mutating ops in
T3.3. Adds tests for ordered multi-observer delivery, empty-observer
no-op, and observer-reads-state-without-deadlock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:39:34 +03:00
mixeme 9931ec1237 T3.1: Add app.Service owning state behind a mutex
Create src/app/service.go: the application-service layer that becomes
the single owner of the durable jobs slice and the transient runtime
map, guarded by a non-reentrant sync.Mutex. NewService wires a loaded
store; Open() is the convenience entry point. Read-only accessors
(Jobs/Runtime/Store) take the lock, and Jobs() returns a copy to keep
callers from mutating Service-owned state.

State-mutating intents and the event/observer machinery are deferred to
T3.2-T3.4. Adds no-Fyne unit tests for runtime construction, copy
isolation, and store wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:34 +03:00
mixeme 98f692658a Bump version to 0.3.4: Phase 2 domain cleanup complete
- Split durable Job from transient JobRuntime
- Extracted Schedule value object
- Made RunJob pure (no job mutation)
- Simplified runtime initialization

No observable behavior changes.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-19 00:10:41 +03:00
mixeme b1874845d5 T2.3: Split domain.Job (durable) from domain.JobRuntime (transient)
Move all transient execution state off domain.Job into a new
domain.JobRuntime, keyed by job ID:

- domain: Job now holds only durable YAML fields; remove the yaml:"-"
  fields (LastRun/NextRun/LastState/Logs/Output) and NextDue. Add
  runtime.go with JobRuntime plus NewRuntime/NewRuntimes constructors,
  which now own the runtime-init logic moved out of normalizeJobs.
- runner: RunJob no longer mutates the job; it is pure and returns the
  RunRecord for the caller to fold into the runtime.
- scheduler: take a shared map[int]*JobRuntime and route status/next-run
  bookkeeping through runtimeFor(job); prepareNextRun writes a *JobRuntime.
- storage: normalizeJobs touches only durable config.
- gui: own the runtime map (NewRuntimes), share it with the scheduler,
  and read/write runtime state via runtimeFor; maintain the map by ID on
  add/edit/delete.
- tests: update scheduler/storage tests to the split; tidy a pre-existing
  import-order nit in scheduler.go.

This also satisfies T2.4 (storage load/save only Job, runtime init in
domain.NewRuntime, round-trip tests), since removing the fields forced it.
Runtime-map ownership remains GUI-side glue until T3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:08:05 +03:00
mixeme ca673f08f9 T2.2: Migrate scheduler to use domain.Schedule
Parse each job's schedule once on load (resetNextRuns) and on edit
(RefreshSchedule) via the new parseJobSchedule helper, caching the
result in a map[int]domain.Schedule keyed by job ID. prepareNextRun
now looks up the cached Schedule instead of re-parsing the string on
every call. Remove the nextRunTime wrapper that did the per-call
parsing. Drop the three scheduler_test.go tests that duplicated
coverage already in domain/schedule_test.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:59:46 +03:00
mixeme 0f17782174 T2.1: Add domain.Schedule value object
Introduce src/domain/schedule.go with a Schedule value object that
centralizes schedule parsing and validation: Parse, Validate, and
Next(time.Time). It owns the cron parser and @every handling, moved out
of the scheduler. The scheduler's nextRunTime is kept as a thin wrapper
delegating to domain.Parse for now (T2.2 will parse once on load/edit).

Add unit tests covering invalid specs, @every intervals, five-field cron,
cron descriptors, whitespace trimming, the zero-value Next, and String.

Mark T2.1 complete in REFACTORING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:55:20 +03:00
mixeme 9938164c7a Update changelog for 0.3.3: Phase 1 refactoring complete 2026-06-18 22:42:42 +03:00
mixeme 80b6892266 Bump version to 0.3.3 2026-06-18 22:41:25 +03:00
mixeme 29c973464c Mark T1.8 complete in REFACTORING.md 2026-06-18 22:40:45 +03:00
mixeme 2f5c920c3e T1.8: Delete empty src/core; move version to src/app
Move version.go from src/core to src/app to prepare for deleting the
now-empty src/core package. Update all imports and build script
references to use src/app.Version instead of src/core.Version.

Fix import aliasing conflict between Fyne's desktop package and our
platform/desktop package by aliasing Fyne imports as fyneapp and
fynedesktop.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 22:40:34 +03:00
mixeme 17c5e15b63 Mark T1.7 complete in REFACTORING.md 2026-06-18 22:38:03 +03:00
mixeme 794ed8061c T1.7: Create src/platform/desktop; move desktop integration
Move desktop_linux.go and desktop_other.go from src/core to the new
src/platform/desktop package. Update src/gui to import and use the
new package location.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 22:37:47 +03:00
mixeme 16d818d03d T1.6: Create src/platform/autostart; move autostart logic
Move autostart_*.go and tests from src/core to src/platform/autostart.
Update src/gui/app.go to call autostart.SetAutostart / autostart.AutostartStatus.
Add quoteDesktopExec to desktop_linux.go (was co-located in autostart_linux.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:35:51 +03:00
mixeme 06edbfff77 T1.5: Create src/storage; move store/paths logic
Move store.go, paths.go, and store_test.go from src/core into the new
src/storage package. Update src/scheduler and src/gui to import storage
instead of core for Store/Paths/OpenStore. Empty the moved files in core
to preserve the package declaration for the remaining core symbols.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:15:40 +03:00
mixeme ad0e45a7dd T1.4: Create src/scheduler; move scheduler logic
Move scheduler.go and scheduler_test.go from src/core to the new
src/scheduler package. The scheduler still takes *[]domain.Job and
*core.Store (storage moves in T1.5). Update src/gui/app.go to import
the new package; rename the local variable to sched to avoid shadowing
the scheduler package name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:03:16 +03:00
mixeme c29d581fb1 T1.3: Create src/runner; move runner logic
Move runner.go, runner_windows.go, runner_other.go, runner_test.go from
src/core to src/runner (package runner). Split helpers into focused files:
invocation*.go, exitcodes.go, logfile.go, cleanup.go. Export LogArguments
and SuccessExitCodesText for use by scheduler's runningOutput helper.
Update src/core/scheduler.go and src/gui/app.go to import src/runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:47:29 +03:00
mixeme f4fb16c0ed T1.2: Create src/platform/winproc; move configureHiddenWindow
Extract the hidden-window logic out of src/core runner and autostart files
into a new platform/winproc package with per-OS build-tag files. All call
sites updated to use winproc.ConfigureHiddenWindow. Builds clean on both
Windows and Linux; all tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:28:25 +03:00
mixeme 80c76a0cba T1.1: Create src/domain; move Job/RunRecord/Config/JobsFile/StartInTrayArgument
Extracts the five domain types out of src/core/model.go into a new
src/domain package (job.go, record.go, config.go). The unexported
nextDue field is promoted to NextDue so it is accessible from core.
All references across src/core, src/gui, and cmd/gosentry are updated
to use domain.TypeName. src/core/model.go is reduced to a bare package
declaration. Windows and Linux cross-compilation both pass; all tests
remain green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:18:57 +03:00
mixeme 462752f995 Mark T0.2 as complete 2026-06-18 20:25:39 +03:00
mixeme ef6902d65c T0.2: Add characterization tests at refactoring seams
Pin current behavior at the three seams that will move during refactoring:

Store (store_test.go):
- TestJobsRoundTrip: all durable Job fields survive a writeYAML→loadOrCreateJobs
  cycle; runtime fields (LastRun, LastState, Logs) do not.
- TestConfigRoundTrip: all Config fields survive a writeYAML→loadOrCreateConfig
  cycle, including non-default booleans and custom dirs.
- TestNormalizeJobsFillsDefaults: blank jobs get default name/schedule/exitcodes
  and the correct LastState/NextRun for enabled vs disabled.

Scheduler (scheduler_test.go):
- TestNextRunTimeRejectsInvalidSchedules: empty, whitespace, bare @every,
  invalid/negative/zero durations, invalid cron, out-of-range minute all return false.
- TestPrepareNextRunSetsDisplayString: valid schedule writes NextRun as
  "YYYY-MM-DD HH:MM:SS" and sets nextDue to the matching time.Time.
- TestPrepareNextRunSetsInvalidScheduleLabel: bad schedule writes "Invalid
  schedule" and zeroes nextDue.

Runner (runner_test.go):
- TestRunJobLogFileAllHeaders: all log header fields are present (job_id,
  job_name, trigger, state, detail, command, arguments, success_exit_codes,
  start_only, stdout, stderr) and time parses as 2006-01-02 15:04:05.
- TestRunJobRecordFields: RunRecord matches the job and trigger; Time parses;
  Output contains stdout/stderr sections.
- TestFormatOutput / TestFormatOutputEmptyStreams: stdout/stderr sections are
  separated by a blank line; empty streams show "<empty>".
- TestLogArguments: empty/whitespace → "<empty>"; CRLF → LF normalised.
- TestSanitizeFileName: special chars → "_"; empty or all-special → "job".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 20:25:27 +03:00
mixeme 520a7ef98b Mark T0.1 as complete 2026-06-18 20:14:06 +03:00
mixeme 0038975adc T0.1: Add test scripts and documentation
Add scripts/test.sh and scripts/test.bat to run go vet and go test -race.
Update docs/TESTS.md with test script usage and reorganized manual test commands.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 20:13:46 +03:00
mixeme f653b1e484 Add task completion checklist to refactoring plan
Track the 30 tasks across 5 phases with checkboxes. Each checkbox can be
marked complete as tasks land and pass review.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 08:19:02 +03:00
mixeme 4c49104cce Add refactoring plan document
Document a phased plan to restructure GoSentry into focused packages
under src/ (domain, storage, runner, scheduler, platform, app, ui) with
an application-service layer that owns state, and link it from the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:16:03 +03:00
55 changed files with 3440 additions and 1107 deletions
+1
View File
@@ -9,6 +9,7 @@ Project notes:
- [Changelog](docs/CHANGELOG.md) - [Changelog](docs/CHANGELOG.md)
- [Roadmap](docs/ROADMAP.md) - [Roadmap](docs/ROADMAP.md)
- [Architecture](docs/ARCHITECTURE.md) - [Architecture](docs/ARCHITECTURE.md)
- [Refactoring plan](docs/REFACTORING.md)
## Features ## Features
+2 -2
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"os" "os"
"gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/gui" "gitea.mixdep.ru/mix/gosentry/src/gui"
) )
@@ -11,7 +11,7 @@ func main() {
// The executable entry point intentionally delegates all startup work to the // The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific // GUI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic. // packaging later without mixing window setup, storage, and scheduler logic.
gui.Run(hasArgument(core.StartInTrayArgument)) gui.Run(hasArgument(domain.StartInTrayArgument))
} }
func hasArgument(argument string) bool { func hasArgument(argument string) bool {
+23
View File
@@ -2,6 +2,29 @@
All notable GoSentry changes are recorded in this file. All notable GoSentry changes are recorded in this file.
## 0.3.4 - 2026-06-19
- Completed Phase 2 refactoring: domain cleanup and value object extraction.
- Split durable job configuration (`domain.Job`) from transient execution state (`domain.JobRuntime`), keyed by job ID.
- Added `domain.Schedule` value object with `Parse`, `Validate`, and `Next(time.Time)` methods for cron/interval parsing.
- Migrated scheduler to parse schedules once at load/edit instead of per tick, removing duplicated parsing.
- Made `RunJob` pure: runner no longer mutates jobs, returning only `RunRecord` for the caller to fold into runtime state.
- Simplified `storage.normalizeJobs` to touch only durable configuration; runtime initialization moved to `domain.NewRuntime`.
- No observable behavior changes; continued internal refactoring toward separated concerns.
## 0.3.3 - 2026-06-18
- Completed Phase 1 refactoring: split the flat `src/core` package into specialized, focused packages:
- `src/domain` for pure types (Job, RunRecord, Config)
- `src/storage` for persistence (Load/Save, Paths, YAML helpers)
- `src/runner` for job execution (RunJob orchestration, logging, exit codes)
- `src/scheduler` for timing loop
- `src/platform/winproc` for cross-platform hidden window configuration
- `src/platform/autostart` for system autostart integration
- `src/platform/desktop` for desktop environment integration
- `src/app` for application-level code (Version, future Service layer)
- No observable behavior changes; internal structure improvements only.
## 0.3.1 - 2026-06-17 ## 0.3.1 - 2026-06-17
- Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction. - Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction.
+304
View File
@@ -0,0 +1,304 @@
# GoSentry Refactoring Plan
Status: proposed — not yet started.
Goal: make the codebase **solid**, **comprehensive**, and **human-readable / maintainable**
without changing observable behavior.
This document is the single source of truth for the refactor. It records the
target architecture, the rationale, and a sequence of small, independently
reviewable tasks. Each task lists the recommended agent model and effort level.
---
## 1. Why refactor
The application works and is well-commented, but its structure does not scale:
| # | Problem | Impact |
|---|---------|--------|
| 1 | `src/gui/app.go` is a 1,057-line monolith | Nothing can be found, reused, or tested in isolation |
| 2 | `src/core` is one flat package mixing 7 concerns | No boundaries; everything can call everything |
| 3 | **Shared mutable `*[]Job`** between GUI and `Scheduler` | GUI mutates the slice with no lock; scheduler locks the same slice → data race |
| 4 | `onChange` mutates Fyne widgets **from the scheduler goroutine** | Latent crash/corruption — Fyne requires UI updates on the main thread |
| 5 | `Job` mixes durable config and runtime state (`yaml:"-"` fields) | The "noise" the model fights to exclude lives in the same struct |
| 6 | Errors swallowed everywhere (`_ = store.SaveJobs(...)`) | Save failures are invisible to the user |
| 7 | No service/controller layer; GUI reaches into `store.Paths`, drives scheduler directly | Business logic is tangled into widget callbacks |
| 8 | Schedule strings re-parsed every tick; no `Schedule` value type | Validation scattered; no single source of truth |
| 9 | Tests only cover `core`; GUI and orchestration untestable | Documented gap in `docs/TESTS.md` |
> Note on layout: the project intentionally **keeps the `src/` directory**. The
> `src/` → `internal/` move was considered and rejected — it is cosmetic for a
> non-imported desktop app and not worth the import-path churn. All packages
> below live under `src/`.
---
## 2. Target architecture
The central change is to **insert an application-service layer** that owns all
state and exposes intent-based methods. This turns the UI into a thin view and
the core packages into stateless engines, dissolving problems 3, 4, 6, and 7.
```
┌──────────────┐ intents ┌─────────────────┐ calls ┌──────────────┐
│ ui (Fyne) │ ───────────▶ │ app.Service │ ─────────▶ │ core engines │
│ thin views │ ◀─────────── │ (sole owner of │ │ scheduler / │
│ fyne.Do only │ events │ state + mutex) │ ◀───────── │ runner / │
└──────────────┘ └─────────────────┘ records │ storage │
└──────────────┘
```
- **One writer.** `app.Service` holds the job list + runtime state behind a
mutex. The UI never mutates state directly — it calls `CreateJob`, `RunNow`,
`SetGlobalPause`, etc.
- **Events flow back** through an observer interface. The UI's listener is the
*only* place that touches widgets, and it marshals onto the main thread with
`fyne.Do`.
- **Core engines are stateless / injected** — scheduler and runner operate on
data passed in, not a shared slice.
### 2.1 Package layout (all under `src/`)
```
cmd/gosentry/
main.go # flag parse → ui.Run
src/
domain/ # pure types, zero external deps
job.go # Job (durable config only — no yaml:"-")
runtime.go # JobRuntime (LastRun/NextRun/State/Output/Logs)
record.go # RunRecord
config.go # Config + StartInTrayArgument
schedule.go # Schedule value object: Parse / Validate / Next()
storage/ # persistence + path resolution + migration
store.go # Load/SaveConfig, Load/SaveJobs
paths.go # ResolvePaths
yaml.go # writeYAML helper
migration.go # pysentry → gosentry legacy handling
scheduler/
scheduler.go # timing loop; drives Service via callbacks
clock.go # Clock interface (real + fake for tests)
runner/
runner.go # RunJob orchestration
invocation.go # build exec.Cmd (shared)
invocation_windows.go # cmd.exe quoting
invocation_other.go # sh -c
exitcodes.go # parse / accept success codes
logfile.go # writeRunLog + sanitizeFileName
cleanup.go # CleanupLogs
platform/
winproc/ # hidden-window helper shared by runner + autostart
winproc_windows.go # CREATE_NO_WINDOW / HideWindow
winproc_other.go # no-op
autostart/
autostart.go # Manager interface + Status type
windows.go linux.go other.go
desktop/
desktop_linux.go other.go
app/
service.go # owns state; CreateJob/UpdateJob/Delete/RunNow/...
events.go # Event types + Observer registration
format.go # display strings (moved out of GUI)
ui/ # renamed from src/gui; thin Fyne views
run.go # Run(): lifecycle, window, tray wiring
mainwindow.go # tab assembly + event listener (fyne.Do)
jobs_view.go # list + details panel + toolbar
job_dialog.go # new/edit form
history_view.go # history table
settings_view.go # settings form
tray.go # system tray
singleinstance.go # localhost IPC
layout.go # minWidthLayout
```
Import paths follow the existing convention, e.g.
`gitea.mixdep.ru/mix/gosentry/src/domain`,
`gitea.mixdep.ru/mix/gosentry/src/app`.
### 2.2 Dependency direction (must stay acyclic)
```
domain ← (no deps)
storage ← domain
runner ← domain, platform/winproc
scheduler← domain
app ← domain, storage, scheduler, runner
ui ← app, domain (Fyne)
platform/autostart, platform/desktop ← (own deps; winproc for windows)
cmd ← ui
```
### 2.3 Key design decisions
1. **Split durable vs. runtime in the domain.** `domain.Job` becomes pure YAML
config (no `yaml:"-"`). Runtime state moves to `domain.JobRuntime`, held by
the service keyed by job ID. (Resolves #5.)
2. **`Schedule` value object.** `schedule.Parse(string) (Schedule, error)`
validates once and exposes `Next(time.Time)`. (Resolves #8.)
3. **Autostart behind a `Manager` interface**, selected per platform — mockable,
no package-level functions.
4. **Injectable `Clock`** in the scheduler → deterministic tests.
5. **Errors surface to the UI.** Service methods return errors; status bar shows
them. No more `_ =` on saves. (Resolves #6.)
6. **Thread-safety contract:** core engines never import Fyne; the UI listener is
the sole widget mutator and always wraps updates in `fyne.Do`. (Resolves #4.)
---
## 3. Task sequence
Tasks are ordered so the tree **compiles and all tests pass after every task**.
Each task is a small, reviewable unit.
**Model guidance**
- `haiku` — mechanical moves, renames, no judgment required.
- `sonnet` — localized logic changes with clear scope.
- `opus` — architecture-shaping work (new layers, concurrency, public APIs).
**Effort guidance** — reasoning depth, not size: `low` / `medium` / `high`.
### Phase 0 — Safety net
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T0.1 | Add `scripts/test.sh` + `.bat` running `go vet ./...` and `go test -race ./...`. Document in `docs/TESTS.md`. | haiku | low |
| T0.2 | Add characterization tests that pin current behavior at seams to be moved: store load→save round-trip, scheduler `nextRunTime`, end-to-end `RunJob` log output. (Some exist; fill gaps.) | sonnet | medium |
### Phase 1 — Split the flat `core` package (no logic change)
Mechanical moves + import fixes only. Behavior identical.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T1.1 | Create `src/domain`; move `Job`, `RunRecord`, `Config`, `JobsFile`, `StartInTrayArgument` from `model.go`. Keep `yaml:"-"` fields for now (split happens in Phase 2). Update all references. | sonnet | medium |
| T1.2 | Create `src/platform/winproc`; move `configureHiddenWindow` + hidden-window flags out of `runner_windows.go` / `runner_other.go`. This breaks the future autostart→runner coupling early. | sonnet | medium |
| T1.3 | Create `src/runner`; move `runner.go`, `runner_windows.go`, `runner_other.go`, `runner_test.go`. Point at `winproc`. Split helpers into `invocation*.go`, `exitcodes.go`, `logfile.go`, `cleanup.go` as the file moves. | sonnet | medium |
| T1.4 | Create `src/scheduler`; move `scheduler.go`, `scheduler_test.go`. Still takes `*[]domain.Job` for now. | sonnet | medium |
| T1.5 | Create `src/storage`; move `store.go`, `paths.go`, `store_test.go`. | sonnet | medium |
| T1.6 | Create `src/platform/autostart`; move `autostart_*.go` + tests. Point at `winproc`. | sonnet | medium |
| T1.7 | Create `src/platform/desktop`; move `desktop_linux.go`, `desktop_other.go`. | haiku | low |
| T1.8 | Delete the now-empty `src/core`; run full build + tests on both platforms (or with build tags) to confirm parity. | haiku | low |
### Phase 2 — Domain cleanup
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T2.1 | Add `src/domain/schedule.go`: `Schedule` value object with `Parse`, `Validate`, `Next(time.Time)`. Unit-test it. Keep `nextRunTime` as a thin wrapper initially. | opus | high |
| T2.2 | Migrate `scheduler` to use `Schedule` (parse on load/edit, not per tick). Remove duplicated parsing. | sonnet | medium |
| T2.3 | Split `domain.Job` (durable) from `domain.JobRuntime` (transient). Remove all `yaml:"-"` fields and `nextDue` from `Job`. Add `runtime.go`. | opus | high |
| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. **(Completed as part of T2.3 — removing the runtime fields from `Job` forced all three deliverables. Runtime-map ownership is deferred to T3.1.)** | sonnet | medium |
> After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime`
> split is wired through temporary glue. Phase 3 removes the sharing.
### Phase 3 — Application service layer
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T3.1 | Create `src/app/service.go`: `Service` owning `[]domain.Job` + `map[int]*domain.JobRuntime` behind a `sync.Mutex`. Constructor wires `storage`. | opus | high |
| T3.2 | Add `src/app/events.go`: `Event` types (job changed, run recorded, scheduler state) + `Observer` registration. Single-threaded dispatch contract documented. | opus | high |
| T3.3 | Move state-mutating operations into the service: `CreateJob`, `UpdateJob`, `DeleteJob`, `SetEnabled`, `RunNow`, `SetGlobalPause`, `UpdateSettings`. Each returns `error`. | opus | high |
| T3.4 | Convert `scheduler` to operate through the service (no `*[]Job`). Scheduler asks the service for due jobs and reports records back; service is the sole writer. Inject `Clock`. | opus | high |
| T3.5 | Move display/format helpers (`displayFolder`, `displayArguments`, `displayRunMode`, `statusText`, …) from GUI into `src/app/format.go`. | haiku | low |
| T3.6 | Add `src/app` unit tests (no Fyne): create/edit/delete, enable/pause, global pause, run-now path with a fake runner + fake clock. Big coverage win. | opus | high |
### Phase 4 — Carve up the GUI
Rename `src/gui``src/ui` and break `app.go` into focused files. The UI now
talks only to `app.Service` and reacts to events via `fyne.Do`.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T4.1 | Rename package `gui``ui`; split lifecycle into `run.go` + `mainwindow.go`. Wire the event listener and route every widget update through `fyne.Do`. (Resolves #4.) | opus | high |
| T4.2 | Extract `jobs_view.go` (list + details + toolbar), driven by service calls + events. | sonnet | medium |
| T4.3 | Extract `job_dialog.go`; validate schedule via `domain.Schedule.Validate`. | sonnet | medium |
| T4.4 | Extract `history_view.go`. | sonnet | medium |
| T4.5 | Extract `settings_view.go`; surface save/autostart/cleanup errors to the status label. (Resolves #6 in UI.) | sonnet | medium |
| T4.6 | Extract `tray.go`, `singleinstance.go`, `layout.go`. | haiku | low |
| T4.7 | Confirm `app.go` is gone and `ui` imports only `app` + `domain` + Fyne. Manual smoke test on each platform. | sonnet | medium |
### Phase 5 — Hardening & docs
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T5.1 | Replace remaining `_ = ...Save...` with propagated/surfaced errors across service + storage. | sonnet | medium |
| T5.2 | Introduce `autostart.Manager` interface + per-platform impls; inject into the service instead of calling package funcs. | sonnet | medium |
| T5.3 | Fill documented test gaps: folder filtering, log cleanup (count + age), settings persistence/migration, concurrent run prevention. | sonnet | high |
| T5.4 | Run `go test -race ./...` clean. Confirm no data race remains. | haiku | low |
| T5.5 | Update `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and the README "Project Layout" section to the new structure. | sonnet | medium |
---
## 3.1 Task completion checklist
Track progress here. Mark tasks complete as they land and pass review.
### Phase 0 — Safety net
- [x] T0.1 — Add test script + `go vet` + `go test -race`
- [x] T0.2 — Add characterization tests
### Phase 1 — Split flat `core` package
- [x] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc
- [x] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
- [x] T1.3 — Create `src/runner`; move runner logic
- [x] T1.4 — Create `src/scheduler`; move scheduler
- [x] T1.5 — Create `src/storage`; move store/paths
- [x] T1.6 — Create `src/platform/autostart`; move autostart logic
- [x] T1.7 — Create `src/platform/desktop`; move desktop integration
- [x] T1.8 — Delete empty `src/core`; build + test both platforms
### Phase 2 — Domain cleanup
- [x] T2.1 — Add `src/domain/schedule.go`; Schedule value object
- [x] T2.2 — Migrate `scheduler` to use Schedule
- [x] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
- [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_
### Phase 3 — Application service layer
- [x] T3.1 — Create `src/app/service.go`; owns state behind mutex
- [x] T3.2 — Add `src/app/events.go`; Event types + Observer
- [x] T3.3 — Add state-mutating operations to service
- [x] T3.4 — Convert `scheduler` to use service; inject Clock
- [x] T3.5 — Move display helpers to `src/app/format.go`
- [x] T3.6 — Add `src/app` unit tests (no Fyne)
### Phase 4 — Carve up the GUI
- [ ] T4.1 — Rename `gui``ui`; split app.go into run.go + mainwindow.go
- [ ] T4.2 — Extract `jobs_view.go`
- [ ] T4.3 — Extract `job_dialog.go`
- [ ] T4.4 — Extract `history_view.go`
- [ ] T4.5 — Extract `settings_view.go`
- [ ] T4.6 — Extract `tray.go`, `singleinstance.go`, `layout.go`
- [ ] T4.7 — Confirm app.go is gone; smoke test both platforms
### Phase 5 — Hardening & docs
- [ ] T5.1 — Surface errors from service + storage
- [ ] T5.2 — Introduce `autostart.Manager` interface
- [ ] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency)
- [ ] T5.4 — Run `go test -race ./...` clean on both platforms
- [ ] T5.5 — Update docs (ARCHITECTURE.md, TESTS.md, README)
---
## 4. Definition of done
- `go vet ./...` clean; `go test -race ./...` green on Windows and Linux.
- No package outside `ui` imports Fyne; no engine mutates UI state.
- `domain.Job` has no `yaml:"-"` fields.
- `app.Service` is the only writer of job/runtime state.
- `src/ui` contains no file over ~250 lines; no single file over ~400.
- `docs/ARCHITECTURE.md` matches the shipped structure.
## 5. Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| Cross-platform code moves break the non-host OS build | Build with both `GOOS=windows` and `GOOS=linux` after each platform-touching task (T1.2, T1.3, T1.6, T1.7). |
| Concurrency change (Phase 3/4) introduces subtle deadlocks | Keep the service mutex non-reentrant; never call back into the UI while holding it; cover with `-race` tests in T3.6. |
| Behavior drift during moves | Characterization tests (T0.2) pin behavior before structural change. |
| Large diff hard to review | Each task is a separate commit/PR; phases land independently. |
+51 -37
View File
@@ -2,6 +2,57 @@
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns. All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
## Running Tests
### Using the test scripts
The repository provides convenience scripts to run all tests with static analysis:
**Unix/Linux/macOS:**
```bash
./scripts/test.sh
```
**Windows:**
```bash
scripts\test.bat
```
Both scripts run:
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
2. `go test -race ./...` — tests with race condition detection enabled
### Manual test commands
Run all tests:
```bash
go test ./...
```
Run all tests with race detection:
```bash
go test -race ./...
```
Run tests with verbose output:
```bash
go test -v ./...
```
Run a specific test by name:
```bash
go test -run TestRunJobWritesLogFile ./src/core
```
Run tests with code coverage:
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
---
## Test Files Overview ## Test Files Overview
### store_test.go ### store_test.go
@@ -107,43 +158,6 @@ Tests Linux autostart entry creation via XDG Desktop Entry files.
--- ---
## Running Tests
### Run all tests in the package
```bash
cd D:\Local\Git\gosentry
go test ./src/core
```
### Run tests with verbose output
```bash
go test -v ./src/core
```
### Run specific test by name
```bash
go test -run TestRunJobWritesLogFile ./src/core
```
### Run Windows-only tests (on Windows)
```bash
go test -v ./src/core # Windows build tags are active
```
### Run Linux-only tests (on Linux)
```bash
go test -v ./src/core # Linux build tags are active
```
### Run with code coverage
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
---
## Test Design Principles ## Test Design Principles
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state. 1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
+2 -2
View File
@@ -4,7 +4,7 @@ set -euo pipefail
# Optional first argument mirrors build-linux.sh. The Docker build still writes # Optional first argument mirrors build-linux.sh. The Docker build still writes
# the final artifact into the local dist/ tree, not into the container. The # the final artifact into the local dist/ tree, not into the container. The
# default includes the application version and target platform. # default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}" tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}" output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
@@ -26,7 +26,7 @@ docker run --rm \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
-w /src \ -w /src \
"$tag" \ "$tag" \
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry' bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
# Icons are embedded in the Go binary, so there is no assets directory to copy # Icons are embedded in the Go binary, so there is no assets directory to copy
# after extracting the Linux executable. # after extracting the Linux executable.
+2 -2
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Optional first argument lets a developer or CI job choose the output path. The # Optional first argument lets a developer or CI job choose the output path. The
# default includes the application version and target platform. # default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}" output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
@@ -17,7 +17,7 @@ export GOARCH=amd64
# -trimpath removes local machine paths from debug/build metadata. -s -w strips # -trimpath removes local machine paths from debug/build metadata. -s -w strips
# symbol/debug tables to keep the desktop binary smaller. # symbol/debug tables to keep the desktop binary smaller.
go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${version}" -o "$output" ./cmd/gosentry go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" -o "$output" ./cmd/gosentry
# The application icon is embedded by Go, so the Linux build does not need a # The application icon is embedded by Go, so the Linux build does not need a
# sidecar assets directory beside the executable. # sidecar assets directory beside the executable.
+4 -4
View File
@@ -9,7 +9,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root" cd "$repo_root"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}" tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
@@ -99,15 +99,15 @@ run_in_builder() {
} }
build_linux_amd64() { build_linux_amd64() {
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry' run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
} }
build_linux_arm64() { build_linux_arm64() {
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry' run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
} }
build_windows_amd64() { build_windows_amd64() {
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry' run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
} }
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++') mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
+2 -2
View File
@@ -6,7 +6,7 @@ REM directory. Move to the repository root (the parent of scripts\) before using
REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc. REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc.
cd /d "%~dp0\.." cd /d "%~dp0\.."
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V" for /f "tokens=4" %%V in ('findstr /C:"var Version" src\app\version.go') do set "VERSION=%%~V"
if "%VERSION%"=="" set "VERSION=0.0.0-dev" if "%VERSION%"=="" set "VERSION=0.0.0-dev"
set "VERSION=%VERSION:"=%" set "VERSION=%VERSION:"=%"
@@ -47,7 +47,7 @@ if %ERRORLEVEL%==0 (
REM -trimpath removes local machine paths from the binary, -s -w reduce binary REM -trimpath removes local machine paths from the binary, -s -w reduce binary
REM size, and -H=windowsgui prevents a separate console window from opening when REM size, and -H=windowsgui prevents a separate console window from opening when
REM the GUI app starts from Explorer or a shortcut. REM the GUI app starts from Explorer or a shortcut.
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry "%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
REM Icons are embedded into the executable, so no assets directory is copied next REM Icons are embedded into the executable, so no assets directory is copied next
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM GoSentry test runner
REM Runs go vet and go test with race detection
echo Running go vet...
go vet ./...
if errorlevel 1 (
echo.
echo ✗ go vet failed
exit /b 1
)
echo.
echo Running go test with race detection...
go test -race ./...
if errorlevel 1 (
echo.
echo ✗ go test failed
exit /b 1
)
echo.
echo ✓ All tests passed
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# GoSentry test runner
# Runs go vet and go test with race detection
set -e
echo "Running go vet..."
go vet ./...
echo ""
echo "Running go test with race detection..."
go test -race ./...
echo ""
echo "✓ All tests passed"
+91
View File
@@ -0,0 +1,91 @@
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
}
func (JobChanged) isEvent() {}
func (RunRecorded) isEvent() {}
func (SchedulerStateChanged) 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)
}
}
+59
View File
@@ -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")
}
}
+89
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+481
View File
@@ -0,0 +1,481 @@
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
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
}
// Async save errors cannot be returned to a caller here; surfacing them
// is deferred to T5.1 with the rest of the swallowed saves.
_ = s.startRunLocked(job, runtime, "Schedule")
startedID = job.ID
break
}
}
s.mu.Unlock()
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.
// Autostart is intentionally left to the caller until T5.2 introduces an
// injectable autostart.Manager.
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()
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)
// Async save errors cannot be returned to a caller; surfacing them is
// deferred to T5.1 along with the rest of the swallowed saves.
_ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
_ = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock()
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
}
+507
View File
@@ -0,0 +1,507 @@
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")
}
}
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.Start(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)
}
}
+166
View File
@@ -0,0 +1,166 @@
package app
import (
"context"
"sync"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"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
// 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. It installs a cancelable run context and a timing
// loop driven by the given clock; every tick calls RunDue. Pass
// scheduler.NewRealClock() in production. Start is expected once, during setup,
// before any concurrent use.
func (s *Service) Start(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
}
return NewService(store, jobs), 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]
}
+57
View File
@@ -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")
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
package core package app
// Version is the application version shown in the GUI and used by build // 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 // 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. // can override it with Go ldflags when CI tags a build.
var Version = "0.3.2" var Version = "0.3.4"
-68
View File
@@ -1,68 +0,0 @@
package core
import "time"
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
// Job is the user-visible scheduled command.
//
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state.
type Job struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// nextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting
// must not persist it.
nextDue time.Time
}
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
-339
View File
@@ -1,339 +0,0 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
started := time.Now()
// 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
// scheduler; later it can become a per-job setting without changing the
// runner contract.
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
var stdout bytes.Buffer
var stderr bytes.Buffer
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
configureHiddenWindow(command)
}
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdout.String(), stderr.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
job.LastRun = now.Format("2006-01-02 15:04:05")
job.LastState = state
job.Output = output
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
record := RunRecord{
Time: job.LastRun,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
// Keep a small in-memory history for the currently running GUI. Full command
// output is persisted to files, so retaining every past record in RAM would
// only duplicate data and make long sessions grow without bound.
job.Logs = append([]RunRecord{record}, job.Logs...)
if len(job.Logs) > 50 {
job.Logs = job.Logs[:50]
}
return record
}
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return ""
}
// 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
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return ""
}
return path
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "job"
}
var builder strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "job"
}
return result
}
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
configureHiddenWindow(command)
}
err := command.Start()
duration := time.Since(started).Round(time.Millisecond)
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func acceptedExitCode(exitCode int, successExitCodes string) bool {
for _, accepted := range parseExitCodes(successExitCodes) {
if exitCode == accepted {
return true
}
}
return false
}
func parseExitCodes(value string) []int {
value = strings.TrimSpace(value)
if value == "" {
return []int{0}
}
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
result := make([]int, 0, len(fields))
seen := map[int]bool{}
for _, field := range fields {
code, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
if len(result) == 0 {
return []int{0}
}
return result
}
func successExitCodesText(job Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Itoa(code))
}
return strings.Join(parts, ",")
}
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func logArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func formatOutput(stdout string, stderr string) string {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
if stdout == "" {
// Showing an explicit placeholder is clearer than an empty panel in the
// GUI: the user can tell that the command ran but produced no stream data.
stdout = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
-252
View File
@@ -1,252 +0,0 @@
package core
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
// It receives a pointer to the jobs slice because the GUI edits the same slice;
// this keeps the early architecture simple while storage and scheduling are
// still in one desktop process.
type Scheduler struct {
store *Store
jobs *[]Job
onChange func(RunRecord)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
paused bool
}
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
store: store,
jobs: jobs,
onChange: onChange,
ctx: ctx,
cancel: cancel,
}
s.resetNextRuns(time.Now())
return s
}
func (s *Scheduler) Start() {
// A one-second ticker is accurate enough for cron-style desktop automation
// and avoids the complexity of maintaining one timer per job. Five-field cron
// expressions have minute precision, while @every values may be shorter for
// testing and lightweight local tasks.
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case now := <-ticker.C:
s.tick(now)
}
}
}()
}
func (s *Scheduler) Stop() {
s.cancel()
}
func (s *Scheduler) SetPaused(paused bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.paused = paused
now := time.Now()
// Pause state is reflected into each job's display string so the list view is
// understandable even before the next scheduler tick.
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
if paused {
job.NextRun = "Scheduler paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) RunNow(index int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return false
}
// Manual runs share the same runner and log writer as scheduled runs. The
// Trigger field is the only difference, which keeps History comparable and
// prevents "Run now" from becoming a separate behavior path.
return s.startRunLocked(index, "Manual")
}
func (s *Scheduler) RefreshSchedule(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return
}
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
return
}
if s.paused {
job.NextRun = "Scheduler paused"
return
}
s.prepareNextRun(job, time.Now())
}
func (s *Scheduler) tick(now time.Time) {
var changed bool
s.mu.Lock()
if !s.paused {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
continue
}
// Run only one due job per tick for now. That avoids overlapping shell
// commands in the GUI process and keeps the first version predictable;
// a future worker pool can add concurrency once cancellation and status
// reporting are more explicit.
changed = s.startRunLocked(index, "Schedule")
break
}
}
s.mu.Unlock()
_ = changed
}
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
job := &(*s.jobs)[index]
if job.LastState == "Running" {
return false
}
jobCopy := *job
job.LastState = "Running"
job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs)
go func() {
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
s.mu.Lock()
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
current.LastRun = record.Time
current.LastState = record.State
current.Output = record.Output
current.Logs = append([]RunRecord{record}, current.Logs...)
if len(current.Logs) > 50 {
current.Logs = current.Logs[:50]
}
s.prepareNextRun(current, time.Now())
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
_ = s.store.SaveJobs(*s.jobs)
}
s.mu.Unlock()
if s.onChange != nil {
s.onChange(record)
}
}()
return true
}
func (s *Scheduler) findJobByIDLocked(id int) *Job {
for index := range *s.jobs {
if (*s.jobs)[index].ID == id {
return &(*s.jobs)[index]
}
}
return nil
}
func runningOutput(job Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\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(logArguments(job.Arguments))
builder.WriteString("\n\nsuccess_exit_codes:\n")
builder.WriteString(successExitCodesText(job))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
func (s *Scheduler) resetNextRuns(now time.Time) {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if schedule == "" {
return time.Time{}, false
}
if strings.HasPrefix(schedule, "@every ") {
// @every is kept alongside cron because it is convenient for quick tests
// and for simple intervals that are awkward to express as five fields.
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return time.Time{}, false
}
return from.Add(interval), true
}
// Standard five-field cron keeps GoSentry compatible with the mental model
// users already know from Unix cron, while robfig/cron handles edge cases
// such as ranges, steps, and day-of-week names.
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
}
-54
View File
@@ -1,54 +0,0 @@
package core
import (
"strings"
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{
Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
SuccessExitCodes: "0,1",
}
output := runningOutput(job, "Manual", started)
for _, want := range []string{
"Running since 2026-06-17 23:40:00",
"Manual",
job.Command,
job.Arguments,
"0,1",
"start_only",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
}
}
}
-38
View File
@@ -1,38 +0,0 @@
package core
import (
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package domain
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
+18
View File
@@ -0,0 +1,18 @@
package domain
// Job is the user-visible scheduled command. It contains only durable
// configuration: every field is persisted to jobs.yaml. Transient execution
// state (last run, next run, command output, in-memory activity) lives in a
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
// configuration and never mixes in process-lifetime bookkeeping.
type Job struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
}
+15
View File
@@ -0,0 +1,15 @@
package domain
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
+49
View File
@@ -0,0 +1,49 @@
package domain
import "time"
// 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
// 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,
// status strings, and scheduling bookkeeping.
type JobRuntime struct {
LastRun string
NextRun string
LastState string
Output string
Logs []RunRecord
// NextDue is the next scheduled execution time, kept as time.Time for
// scheduler comparisons. NextRun above is its formatted display string and is
// the only form shown in the GUI.
NextDue time.Time
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
// job. Enabled jobs start "Ready" and wait for the scheduler to compute their
// first run; disabled jobs start "Paused".
func NewRuntime(job Job) *JobRuntime {
runtime := &JobRuntime{
LastRun: "Never",
Output: "No command output captured yet.",
}
if job.Enabled {
runtime.LastState = "Ready"
runtime.NextRun = "After start"
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
}
return runtime
}
// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is
// the convenience entry point used when a whole jobs file has just been loaded.
func NewRuntimes(jobs []Job) map[int]*JobRuntime {
runtimes := make(map[int]*JobRuntime, len(jobs))
for _, job := range jobs {
runtimes[job.ID] = NewRuntime(job)
}
return runtimes
}
+87
View File
@@ -0,0 +1,87 @@
package domain
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// cronParser accepts standard five-field cron expressions (minute, hour, day of
// month, month, day of week) plus descriptors such as "@daily". It is the single
// source of truth for what GoSentry considers a valid cron schedule.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// everyPrefix marks the "@every <duration>" form, which is kept alongside cron
// because it is convenient for quick tests and for simple intervals that are
// awkward to express as five fields.
const everyPrefix = "@every "
// Schedule is a parsed, validated job schedule. It supports two forms:
//
// - "@every <duration>" intervals (e.g. "@every 10s"), and
// - standard five-field cron expressions (e.g. "*/5 * * * *").
//
// Parsing once and reusing the value avoids re-validating and re-parsing the
// same string on every scheduler tick. A zero Schedule is invalid; its Next
// method returns the zero time.
type Schedule struct {
raw string
every time.Duration // > 0 when the schedule is an "@every" interval
cron cron.Schedule // non-nil when the schedule is a cron expression
}
// Parse validates spec and returns a reusable Schedule. It returns an error
// describing why the schedule is unusable, which callers can surface to the user.
func Parse(spec string) (Schedule, error) {
trimmed := strings.TrimSpace(spec)
if trimmed == "" {
return Schedule{}, fmt.Errorf("schedule is empty")
}
if strings.HasPrefix(trimmed, everyPrefix) {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(trimmed, everyPrefix)))
if err != nil {
return Schedule{}, fmt.Errorf("invalid %q duration: %w", strings.TrimSpace(everyPrefix), err)
}
if interval <= 0 {
return Schedule{}, fmt.Errorf("%q duration must be positive, got %s", strings.TrimSpace(everyPrefix), interval)
}
return Schedule{raw: trimmed, every: interval}, nil
}
// robfig/cron handles edge cases such as ranges, steps, and day-of-week names,
// keeping GoSentry compatible with the mental model users know from Unix cron.
parsed, err := cronParser.Parse(trimmed)
if err != nil {
return Schedule{}, fmt.Errorf("invalid cron expression: %w", err)
}
return Schedule{raw: trimmed, cron: parsed}, nil
}
// Validate reports whether spec is a usable schedule string. It is a convenience
// wrapper around Parse for callers (such as form validation) that only need the
// yes/no answer and the error message.
func Validate(spec string) error {
_, err := Parse(spec)
return err
}
// Next returns the next time the schedule fires strictly after from. For an
// "@every" interval this is from plus the interval; for a cron expression it is
// the cron library's next matching time. A zero (unparsed) Schedule returns the
// zero time.
func (s Schedule) Next(from time.Time) time.Time {
switch {
case s.every > 0:
return from.Add(s.every)
case s.cron != nil:
return s.cron.Next(from)
default:
return time.Time{}
}
}
// String returns the original, trimmed schedule specification.
func (s Schedule) String() string {
return s.raw
}
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"testing"
"time"
)
func TestParseRejectsInvalidSchedules(t *testing.T) {
cases := []struct {
spec string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every ", "@every with trailing space but no duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
{"* * * *", "too few cron fields"},
}
for _, tc := range cases {
if _, err := Parse(tc.spec); err == nil {
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
if err := Validate(tc.spec); err == nil {
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
}
}
func TestParseEveryInterval(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse("@every 10s")
if err != nil {
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse(" @every 90m ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronExpression(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("*/5 * * * *")
if err != nil {
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronDescriptor(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("@daily")
if err != nil {
t.Fatalf("Parse(@daily): unexpected error: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestValidateAcceptsValidSchedules(t *testing.T) {
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
if err := Validate(spec); err != nil {
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
}
}
}
func TestZeroScheduleNextIsZero(t *testing.T) {
var s Schedule
if got := s.Next(time.Now()); !got.IsZero() {
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
}
}
func TestStringReturnsTrimmedSpec(t *testing.T) {
s, err := Parse(" */5 * * * * ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.String(), "*/5 * * * *"; got != want {
t.Fatalf("String: got %q, want %q", got, want)
}
}
+161 -220
View File
@@ -13,13 +13,17 @@ import (
"time" "time"
"gitea.mixdep.ru/mix/gosentry/assets" "gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" fyneapp "fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop" fynedesktop "fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
@@ -39,8 +43,8 @@ const singleInstanceShowCommand = "show"
// The GUI package aliases core types to keep widget callbacks short. The actual // The GUI package aliases core types to keep widget callbacks short. The actual
// durable model still lives in src/core, so GUI code does not define a second // durable model still lives in src/core, so GUI code does not define a second
// copy of the scheduler data. // copy of the scheduler data.
type job = core.Job type job = domain.Job
type event = core.RunRecord type event = domain.RunRecord
func Run(startInTray bool) { func Run(startInTray bool) {
started := time.Now() started := time.Now()
@@ -54,10 +58,10 @@ func Run(startInTray bool) {
// A stable app ID lets Fyne persist desktop preferences consistently across // A stable app ID lets Fyne persist desktop preferences consistently across
// launches and gives tray/window integration a predictable identity. // launches and gives tray/window integration a predictable identity.
a := app.NewWithID(appID) a := fyneapp.NewWithID(appID)
a.SetIcon(loadAppIcon()) a.SetIcon(loadAppIcon())
w := a.NewWindow("GoSentry " + core.Version) w := a.NewWindow("GoSentry " + app.Version)
configureSystemTray(a, w) configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720)) w.Resize(fyne.NewSize(1120, 720))
content, recordStartup := newMainView(w) content, recordStartup := newMainView(w)
@@ -85,7 +89,7 @@ func loadAppIcon() fyne.Resource {
} }
func configureSystemTray(a fyne.App, w fyne.Window) { func configureSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(desktop.App) desk, ok := a.(fynedesktop.App)
if !ok { if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently // Not every Fyne driver exposes desktop tray features. Returning silently
// keeps the same binary usable on platforms or sessions without a tray. // keeps the same binary usable on platforms or sessions without a tray.
@@ -159,19 +163,44 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
} }
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
store, jobs, err := core.OpenStore() svc, err := app.Open()
if err != nil { if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {} return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
} }
if iconPath, err := core.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil { store := svc.Store()
if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
store.Paths.DesktopIcon = iconPath store.Paths.DesktopIcon = iconPath
} }
events := collectActivity(jobs)
// The GUI keeps the loaded jobs slice in memory and persists changes after // app.Service is the single owner of job and runtime state. The GUI keeps a
// each edit/run. This keeps the first version responsive and easy to reason // read snapshot of the durable jobs plus a map of the live runtime pointers,
// about; a database would be unnecessary overhead for one YAML file. // both refreshed from the Service after every change. The Service — not the
nextJobID := nextID(jobs) // GUI — mutates state and drives the scheduler, so there is no shared *[]Job.
jobs := svc.Jobs()
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
syncFromService := func() {
jobs = svc.Jobs()
for id := range runtimes {
delete(runtimes, id)
}
for _, current := range jobs {
if runtime := svc.Runtime(current.ID); runtime != nil {
runtimes[current.ID] = runtime
}
}
}
syncFromService()
runtimeFor := func(index int) *domain.JobRuntime {
if index < 0 || index >= len(jobs) {
return &domain.JobRuntime{}
}
if runtime := runtimes[jobs[index].ID]; runtime != nil {
return runtime
}
return &domain.JobRuntime{}
}
events := collectActivity(jobs, runtimes)
selected := 0 selected := 0
selectedFolder := allFolders selectedFolder := allFolders
schedulerPaused := false schedulerPaused := false
@@ -182,14 +211,15 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
schedule := newJobDetailLabel(jobs[selected].Schedule) schedule := newJobDetailLabel(jobs[selected].Schedule)
command := newJobDetailLabel(jobs[selected].Command) command := newJobDetailLabel(jobs[selected].Command)
arguments := newJobDetailLabel(jobs[selected].Arguments) arguments := newJobDetailLabel(jobs[selected].Arguments)
successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes)) successExitCodes := newJobDetailLabel(app.DisplaySuccessExitCodes(jobs[selected].SuccessExitCodes))
runMode := newJobDetailLabel(displayRunMode(jobs[selected])) runMode := newJobDetailLabel(app.DisplayRunMode(jobs[selected]))
lastRun := newJobDetailLabel(jobs[selected].LastRun) selectedRuntime := runtimeFor(selected)
nextRun := newJobDetailLabel(jobs[selected].NextRun) lastRun := newJobDetailLabel(selectedRuntime.LastRun)
state := newJobDetailLabel(jobs[selected].LastState) nextRun := newJobDetailLabel(selectedRuntime.NextRun)
state := newJobDetailLabel(selectedRuntime.LastState)
schedulerState := widget.NewLabel("Scheduler running") schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewTextGrid() commandOutput := widget.NewTextGrid()
commandOutput.SetText(jobs[selected].Output) commandOutput.SetText(selectedRuntime.Output)
commandOutputScroll := container.NewScroll(commandOutput) commandOutputScroll := container.NewScroll(commandOutput)
// Command output can contain long lines and preserved whitespace. TextGrid is // Command output can contain long lines and preserved whitespace. TextGrid is
// used instead of Label so stdout/stderr remains readable and does not vanish // used instead of Label so stdout/stderr remains readable and does not vanish
@@ -208,14 +238,14 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
events = append(events, newEvent(0, "Application", "Started", detail)) events = append(events, newEvent(0, "Application", "Started", detail))
history.Refresh() history.Refresh()
} }
selectedLogs := append([]event(nil), jobs[selected].Logs...) selectedLogs := append([]event(nil), selectedRuntime.Logs...)
jobLogs := widget.NewList( jobLogs := widget.NewList(
func() int { func() int {
return len(selectedLogs) return len(selectedLogs)
}, },
func() fyne.CanvasObject { return widget.NewLabel("log") }, func() fyne.CanvasObject { return widget.NewLabel("log") },
func(id widget.ListItemID, item fyne.CanvasObject) { func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(eventText(selectedLogs[id])) item.(*widget.Label).SetText(app.EventText(selectedLogs[id]))
}, },
) )
@@ -239,29 +269,30 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
} }
selected = index selected = index
current := jobs[selected] current := jobs[selected]
runtime := runtimeFor(selected)
title.SetText(current.Name) title.SetText(current.Name)
folder.SetText(displayFolder(current.Folder)) folder.SetText(app.DisplayFolder(current.Folder))
schedule.SetText(current.Schedule) schedule.SetText(current.Schedule)
command.SetText(current.Command) command.SetText(current.Command)
arguments.SetText(displayArguments(current.Arguments)) arguments.SetText(app.DisplayArguments(current.Arguments))
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) successExitCodes.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
runMode.SetText(displayRunMode(current)) runMode.SetText(app.DisplayRunMode(current))
lastRun.SetText(current.LastRun) lastRun.SetText(runtime.LastRun)
nextRun.SetText(current.NextRun) nextRun.SetText(runtime.NextRun)
state.SetText(current.LastState) state.SetText(runtime.LastState)
commandOutput.SetText(current.Output) commandOutput.SetText(runtime.Output)
selectedLogs = append(selectedLogs[:0], current.Logs...) selectedLogs = append(selectedLogs[:0], runtime.Logs...)
} }
refresh := func() { refresh := func() {
// Several callbacks mutate jobs, filters, and event history. A single // Several callbacks change jobs, filters, and event history. A single
// refresh closure keeps the different widgets synchronized after each // refresh closure re-reads the Service snapshot and keeps the different
// mutation without introducing a heavier state-management layer. // widgets synchronized after each change, without a heavier state layer.
syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder) filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected) updateDetails(selected)
jobLogs.Refresh() jobLogs.Refresh()
history.Refresh() history.Refresh()
} }
var scheduler *core.Scheduler
list := widget.NewList( list := widget.NewList(
func() int { return len(filteredJobs) }, func() int { return len(filteredJobs) },
@@ -281,8 +312,8 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
name.SetText(current.Name) name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one // Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs. // metadata line so the left pane stays useful even with many jobs.
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current)) meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
status.SetText(statusText(current)) status.SetText(app.StatusText(current, runtimes[current.ID]))
}, },
) )
list.OnSelected = func(id widget.ListItemID) { list.OnSelected = func(id widget.ListItemID) {
@@ -315,27 +346,27 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
folderSelect.SetSelected(selectedFolder) folderSelect.SetSelected(selectedFolder)
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
saved.ID = nextJobID // The Service assigns the ID, stores the job, records the "Created"
nextJobID++ // activity, and emits events. The observer appends those to History; we
jobs = append(jobs, saved) // only refresh the snapshot and move the selection to the new job.
selected = len(jobs) - 1 created, err := svc.CreateJob(saved)
created := newEvent(saved.ID, saved.Name, "Created", "Job was added") if err != nil {
// UI events are kept in memory for the current session. They explain dialog.ShowError(err, w)
// user actions in History, while command output remains in log files. return
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) }
events = append(events, created) syncFromService()
_ = store.SaveJobs(jobs)
folderSelect.Options = folderOptions(jobs) folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh() folderSelect.Refresh()
targetFolder := filterValue(saved.Folder) targetFolder := filterValue(created.Folder)
if selectedFolder != allFolders && selectedFolder != targetFolder { if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder) folderSelect.SetSelected(targetFolder)
} }
selected = indexOfID(jobs, created.ID)
filteredJobs = filteredJobIndexes(jobs, selectedFolder) filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh() list.Refresh()
list.Select(displayIndex(filteredJobs, selected)) list.Select(app.DisplayIndex(filteredJobs, selected))
refresh() refresh()
}) })
}) })
@@ -344,17 +375,15 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
return return
} }
showJobDialog(w, "Edit job", jobs[selected], func(saved job) { showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
// The job keeps its ID, so the Service preserves the runtime (keyed by
// ID), reflects any enabled/disabled change, recomputes the next run, and
// emits the "Updated" activity the observer records.
saved.ID = jobs[selected].ID saved.ID = jobs[selected].ID
saved.Logs = jobs[selected].Logs if err := svc.UpdateJob(saved); err != nil {
saved.Output = jobs[selected].Output dialog.ShowError(err, w)
jobs[selected] = saved return
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
events = append(events, updated)
if scheduler != nil {
scheduler.RefreshSchedule(selected)
} }
_ = store.SaveJobs(jobs) syncFromService()
folderSelect.Options = folderOptions(jobs) folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh() folderSelect.Refresh()
list.Refresh() list.Refresh()
@@ -371,7 +400,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return return
} }
if !scheduler.RunNow(selected) { // RunNow refuses an already-running job (it returns an error); the GUI has
// always ignored that case silently, so the run simply does not start.
if err := svc.RunNow(jobs[selected].ID); err != nil {
return return
} }
list.Refresh() list.Refresh()
@@ -379,35 +410,23 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
}) })
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil) stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
stopAllButton.OnTapped = func() { stopAllButton.OnTapped = func() {
// SetGlobalPause flips the Service's pause flag, updates every job's
// next-run text, and emits the activity record the observer logs. Mirror the
// new state into the local flag and the controls; revert it if the save fails.
schedulerPaused = !schedulerPaused schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused
dialog.ShowError(err, w)
return
}
if schedulerPaused { if schedulerPaused {
schedulerState.SetText("Scheduler paused") schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Resume all") stopAllButton.SetText("Resume all")
stopAllButton.SetIcon(theme.MediaPlayIcon()) stopAllButton.SetIcon(theme.MediaPlayIcon())
for index := range jobs {
if jobs[index].Enabled {
jobs[index].NextRun = "Scheduler paused"
}
}
if scheduler != nil {
scheduler.SetPaused(true)
}
events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused"))
} else { } else {
schedulerState.SetText("Scheduler running") schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Pause all") stopAllButton.SetText("Pause all")
stopAllButton.SetIcon(theme.MediaStopIcon()) stopAllButton.SetIcon(theme.MediaStopIcon())
for index := range jobs {
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
// The scheduler will calculate the exact next run when it is
// resumed; this interim text prevents a stale paused timestamp.
jobs[index].NextRun = "Waiting for scheduler"
}
}
if scheduler != nil {
scheduler.SetPaused(false)
}
events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed"))
} }
list.Refresh() list.Refresh()
refresh() refresh()
@@ -416,28 +435,14 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
} }
current := &jobs[selected] // SetEnabled toggles the job, updates its runtime/next-run, and records the
current.Enabled = !current.Enabled // "Resumed"/"Paused" activity the observer logs.
if current.Enabled { current := jobs[selected]
current.LastState = "Ready" if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
current.NextRun = "Waiting for scheduler" dialog.ShowError(err, w)
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled") return
current.Logs = append([]event{resumed}, current.Logs...)
events = append(events, resumed)
if scheduler != nil {
scheduler.RefreshSchedule(selected)
}
} else {
current.LastState = "Paused"
current.NextRun = "Paused"
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
current.Logs = append([]event{paused}, current.Logs...)
events = append(events, paused)
if scheduler != nil {
scheduler.RefreshSchedule(selected)
}
} }
_ = store.SaveJobs(jobs) syncFromService()
list.Refresh() list.Refresh()
refresh() refresh()
}) })
@@ -452,7 +457,14 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
if !confirm { if !confirm {
return return
} }
jobs = append(jobs[:selected], jobs[selected+1:]...) // The Service removes the job and its runtime, persists, and records the
// "Deleted" activity the observer logs; the GUI re-reads the snapshot and
// fixes up the folder filter and selection.
if err := svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, w)
return
}
syncFromService()
folderSelect.Options = folderOptions(jobs) folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh() folderSelect.Refresh()
filteredJobs = filteredJobIndexes(jobs, selectedFolder) filteredJobs = filteredJobIndexes(jobs, selectedFolder)
@@ -466,11 +478,9 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
} else { } else {
selected = filteredJobs[0] selected = filteredJobs[0]
} }
events = append(events, newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed"))
_ = store.SaveJobs(jobs)
list.Refresh() list.Refresh()
if selected >= 0 { if selected >= 0 {
list.Select(displayIndex(filteredJobs, selected)) list.Select(app.DisplayIndex(filteredJobs, selected))
} }
refresh() refresh()
}, w) }, w)
@@ -501,20 +511,26 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
jobLogs, jobLogs,
) )
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) { // The Service announces every change through events. This single listener is
// Scheduled runs happen on the scheduler goroutine. The callback updates // where the GUI reacts: it appends run/activity records to History and redraws.
// the shared in-memory event list so History reflects background activity. // Scheduled and manual completions fire it from the run goroutine; UI actions
events = append(events, record) // fire it synchronously. Marshaling these widget updates onto the main thread
// (fyne.Do) is wired in T4.1 — for now this matches the prior direct refresh.
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
if recorded, ok := ev.(app.RunRecorded); ok {
events = append(events, recorded.Record)
}
refresh() refresh()
}) list.Refresh()
scheduler.Start() }))
svc.Start(scheduler.NewRealClock())
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details)) jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView), container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
) )
tabs.SetTabLocation(container.TabLocationTop) tabs.SetTabLocation(container.TabLocationTop)
@@ -553,13 +569,6 @@ func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size)
} }
} }
func statusText(j job) string {
if !j.Enabled {
return "Paused"
}
return j.LastState
}
func newEvent(jobID int, jobName string, state string, detail string) event { func newEvent(jobID int, jobName string, state string, detail string) event {
// Use the same timestamp shape as command run records so the History tab is // Use the same timestamp shape as command run records so the History tab is
// visually consistent across startup, UI actions, manual runs, and schedules. // visually consistent across startup, UI actions, manual runs, and schedules.
@@ -573,24 +582,15 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
} }
} }
func eventText(e event) string { func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
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)
}
func collectActivity(jobs []job) []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.yaml 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.
events = append(events, current.Logs...) if runtime := runtimes[current.ID]; runtime != nil {
events = append(events, runtime.Logs...)
}
} }
sort.SliceStable(events, func(left int, right int) bool { sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time return events[left].Time < events[right].Time
@@ -598,14 +598,13 @@ func collectActivity(jobs []job) []event {
return events return events
} }
func nextID(jobs []job) int { func indexOfID(jobs []job, id int) int {
next := 1 for index, current := range jobs {
for _, current := range jobs { if current.ID == id {
if current.ID >= next { return index
next = current.ID + 1
} }
} }
return next return 0
} }
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
@@ -669,49 +668,6 @@ func filterValue(folder string) string {
return strings.TrimSpace(folder) return strings.TrimSpace(folder)
} }
func displayFolder(folder string) string {
if strings.TrimSpace(folder) == "" {
return "(" + noFolder + ")"
}
return strings.TrimSpace(folder)
}
func displayArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "(none)"
}
return strings.TrimSpace(arguments)
}
func displaySuccessExitCodes(codes string) string {
if strings.TrimSpace(codes) == "" {
return "0"
}
return strings.TrimSpace(codes)
}
func displayRunMode(current job) string {
if current.StartOnly {
return "Start only"
}
return "Wait for completion"
}
func displayInvocation(current job) string {
if strings.TrimSpace(current.Arguments) == "" {
return current.Command
}
return current.Command + " " + strings.ReplaceAll(strings.TrimSpace(current.Arguments), "\n", " ")
}
func displayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes {
if index == jobIndex {
return display
}
}
return 0
}
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
name := widget.NewEntry() name := widget.NewEntry()
@@ -731,7 +687,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
arguments.SetText(current.Arguments) arguments.SetText(current.Arguments)
successExitCodes := widget.NewEntry() successExitCodes := widget.NewEntry()
successExitCodes.SetPlaceHolder("0") successExitCodes.SetPlaceHolder("0")
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) successExitCodes.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
startOnly := widget.NewCheck("Start only, do not wait for exit", nil) startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
startOnly.SetChecked(current.StartOnly) startOnly.SetChecked(current.StartOnly)
enabled := widget.NewCheck("Enabled", nil) enabled := widget.NewCheck("Enabled", nil)
@@ -772,18 +728,9 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
} }
current.StartOnly = startOnly.Checked current.StartOnly = startOnly.Checked
current.Enabled = enabled.Checked current.Enabled = enabled.Checked
if current.LastRun == "" { // The dialog only edits durable configuration now. Runtime status is
current.LastRun = "Never" // initialized (new jobs) or updated (edits) by the caller against the
} // runtime map, keyed by job ID.
if current.Enabled {
current.NextRun = "Waiting for scheduler"
if current.LastState == "" || current.LastState == "Paused" {
current.LastState = "Ready"
}
} else {
current.NextRun = "Paused"
current.LastState = "Paused"
}
onSave(current) onSave(current)
}, },
w, w,
@@ -901,12 +848,13 @@ func logFileName(path string) string {
return path return path
} }
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
refreshAutostartStatus := func() { refreshAutostartStatus := func() {
ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath) ok, message := autostart.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
if ok { if ok {
autostartStatus.SetText("OK: " + message) autostartStatus.SetText("OK: " + message)
return return
@@ -952,7 +900,6 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
settingsStatus.SetText("Max log age days must be a positive number") settingsStatus.SetText("Max log age days must be a positive number")
return return
} }
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
if strings.TrimSpace(jobsDir.Text) == "" { if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required") settingsStatus.SetText("Jobs directory is required")
return return
@@ -961,35 +908,29 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
settingsStatus.SetText("Logs directory is required") settingsStatus.SetText("Logs directory is required")
return return
} }
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text) // Build the new config from the form and hand it to the Service, which
store.Config.MaxLogFiles = files // validates it, persists config and jobs to the (possibly new) directory,
store.Config.MaxLogAgeDays = days // and runs log cleanup so tightened retention limits take effect at once.
store.Config.StartOnLogin = startOnLogin.Checked config := store.Config
store.Config.KeepRunningInTray = minimizeToTray.Checked config.JobsDir = strings.TrimSpace(jobsDir.Text)
store.Config.NotifyOnFailure = notifications.Checked config.LogsDir = strings.TrimSpace(logsDir.Text)
if err := store.SaveConfig(); err != nil { config.MaxLogFiles = files
config.MaxLogAgeDays = days
config.StartOnLogin = startOnLogin.Checked
config.KeepRunningInTray = minimizeToTray.Checked
config.NotifyOnFailure = notifications.Checked
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return return
} }
if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil { // Autostart is platform integration the Service leaves to the caller (until
// T5.2 introduces an injectable autostart.Manager), so apply it here.
if err := autostart.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
refreshAutostartStatus() refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error()) settingsStatus.SetText("Saved, autostart failed: " + err.Error())
return return
} }
refreshAutostartStatus() refreshAutostartStatus()
// When the jobs directory changes, save the currently loaded jobs to the
// newly resolved path immediately. That makes the setting visible on disk
// without requiring a restart or a separate migration command.
if err := store.SaveJobs(*jobs); err != nil {
settingsStatus.SetText("Jobs save failed: " + err.Error())
return
}
// Cleanup runs on settings save so a user who tightens retention limits
// sees the new policy take effect right away.
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
return
}
settingsStatus.SetText("Saved") settingsStatus.SetText("Saved")
}) })
@@ -1009,7 +950,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
settingsStatus, settingsStatus,
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("GoSentry", widget.NewLabel(core.Version)), settingsRow("GoSentry", widget.NewLabel(app.Version)),
settingsRow("Go", widget.NewLabel(runtime.Version())), settingsRow("Go", widget.NewLabel(runtime.Version())),
settingsRow("Fyne", widget.NewLabel(fyneVersion())), settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))), settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
@@ -1,6 +1,6 @@
//go:build linux //go:build linux
package core package autostart
import ( import (
"fmt" "fmt"
@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
const autostartDesktopFileName = "gosentry.desktop" const autostartDesktopFileName = "gosentry.desktop"
@@ -43,7 +45,7 @@ Exec=%s %s
%s %s
Terminal=false Terminal=false
X-GNOME-Autostart-enabled=true X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath)) `, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
} }
@@ -75,7 +77,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil { if readErr != nil {
return false, "Autostart desktop entry is missing" return false, "Autostart desktop entry is missing"
} }
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) { if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable" return false, "Autostart desktop entry points to another executable"
} }
@@ -1,12 +1,14 @@
//go:build linux //go:build linux
package core package autostart
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestLinuxAutostartStartsInTray(t *testing.T) { func TestLinuxAutostartStartsInTray(t *testing.T) {
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Fatalf("read desktop entry: %v", err) t.Fatalf("read desktop entry: %v", err)
} }
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) { if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data) t.Fatalf("desktop entry does not start in tray: %s", data)
} }
@@ -1,6 +1,6 @@
//go:build !windows && !linux //go:build !windows && !linux
package core package autostart
import "fmt" import "fmt"
@@ -1,4 +1,4 @@
package core package autostart
import ( import (
"fmt" "fmt"
@@ -6,6 +6,9 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
) )
const autostartName = "GoSentry" const autostartName = "GoSentry"
@@ -69,7 +72,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) { if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable" return false, "Autostart shortcut points to another executable"
} }
if strings.TrimSpace(arguments) != StartInTrayArgument { if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray" return false, "Autostart shortcut does not start in tray"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
@@ -101,11 +104,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(), command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath, "GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath, "GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument, "GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory, "GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath, "GOSENTRY_ICON_PATH="+iconPath,
) )
configureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
if output, err := command.CombinedOutput(); err != nil { if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output))) return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
} }
@@ -123,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)` script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script) command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath) command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
if err != nil { if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output))) return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
@@ -153,7 +156,7 @@ func removeIfExists(path string) error {
func cleanupLegacyRegistryAutostart() error { func cleanupLegacyRegistryAutostart() error {
for _, name := range []string{legacyAutostartName, autostartName} { for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f") command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
configureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
_ = command.Run() _ = command.Run()
} }
return nil return nil
@@ -162,7 +165,7 @@ func cleanupLegacyRegistryAutostart() error {
func legacyRegistryAutostartExists() bool { func legacyRegistryAutostartExists() bool {
for _, name := range []string{legacyAutostartName, autostartName} { for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name) command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
configureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
if command.Run() == nil { if command.Run() == nil {
return true return true
} }
@@ -1,12 +1,14 @@
//go:build windows //go:build windows
package core package autostart
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"syscall" "syscall"
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
) )
func TestParseRegistryRunValue(t *testing.T) { func TestParseRegistryRunValue(t *testing.T) {
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
if !sameWindowsPath(actual, targetPath) { if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
} }
if arguments != StartInTrayArgument { if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
} }
} }
@@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
if !sameWindowsPath(actual, targetPath) { if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
} }
if arguments != StartInTrayArgument { if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
} }
} }
@@ -1,11 +1,12 @@
//go:build linux //go:build linux
package core package desktop
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
) )
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) { func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
@@ -40,6 +41,10 @@ StartupWMClass=%s
return iconPath, nil return iconPath, nil
} }
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func xdgDataHome() (string, error) { func xdgDataHome() (string, error) {
dataHome := os.Getenv("XDG_DATA_HOME") dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" { if dataHome == "" {
@@ -1,6 +1,6 @@
//go:build !linux //go:build !linux
package core package desktop
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) { func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
return "", nil return "", nil
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package winproc
import "os/exec"
// ConfigureHiddenWindow is a no-op on non-Windows platforms: launching sh -c
// from a desktop process does not create a new console window in the same way
// Windows does.
func ConfigureHiddenWindow(command *exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package winproc
import (
"os/exec"
"syscall"
)
// ConfigureHiddenWindow suppresses the console window that Windows would
// otherwise flash when running a child process from a GUI application.
// CREATE_NO_WINDOW keeps cmd.exe and simple console tools quiet while
// stdout/stderr are still captured through pipes.
func ConfigureHiddenWindow(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
}
+61
View File
@@ -0,0 +1,61 @@
package runner
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package runner
import (
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func acceptedExitCode(exitCode int, successExitCodes string) bool {
for _, accepted := range parseExitCodes(successExitCodes) {
if exitCode == accepted {
return true
}
}
return false
}
func parseExitCodes(value string) []int {
value = strings.TrimSpace(value)
if value == "" {
return []int{0}
}
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
result := make([]int, 0, len(fields))
seen := map[int]bool{}
for _, field := range fields {
code, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
if len(result) == 0 {
return []int{0}
}
return result
}
func SuccessExitCodesText(job domain.Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Itoa(code))
}
return strings.Join(parts, ",")
}
func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) }
+68
View File
@@ -0,0 +1,68 @@
package runner
import (
"context"
"os"
"os/exec"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func LogArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func logArguments(arguments string) string { return LogArguments(arguments) }
@@ -1,6 +1,6 @@
//go:build !windows //go:build !windows
package core package runner
import ( import (
"context" "context"
@@ -12,9 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd {
// and avoids a hard dependency on a larger shell such as bash. // and avoids a hard dependency on a larger shell such as bash.
return exec.CommandContext(ctx, "sh", "-c", command) return exec.CommandContext(ctx, "sh", "-c", command)
} }
func configureHiddenWindow(command *exec.Cmd) {
// Non-Windows platforms do not create a new console window for sh -c from a
// desktop process in the same way Windows does, so no extra process attribute
// is required here.
}
@@ -1,4 +1,4 @@
package core package runner
import ( import (
"context" "context"
@@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool {
command[1] == ':' && command[1] == ':' &&
(command[2] == '\\' || command[2] == '/') (command[2] == '\\' || command[2] == '/')
} }
func configureHiddenWindow(command *exec.Cmd) {
// GoSentry is a GUI scheduler, so child commands should not flash a console
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
// quiet while stdout/stderr are still captured through pipes.
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
}
+55
View File
@@ -0,0 +1,55 @@
package runner
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return ""
}
// 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
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return ""
}
return path
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "job"
}
var builder strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "job"
}
return result
}
+137
View File
@@ -0,0 +1,137 @@
package runner
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
started := time.Now()
// 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
// scheduler; later it can become a per-job setting without changing the
// runner contract.
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
var stdoutBuf strings.Builder
var stderrBuf strings.Builder
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
winproc.ConfigureHiddenWindow(command)
}
command.Stdout = &stdoutBuf
command.Stderr = &stderrBuf
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
timestamp := now.Format("2006-01-02 15:04:05")
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
// 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
// longer lives on Job, so there is nothing on the job to mutate here.
return domain.RunRecord{
Time: timestamp,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
}
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
winproc.ConfigureHiddenWindow(command)
}
err := command.Start()
duration := time.Since(started).Round(time.Millisecond)
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job domain.Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func formatOutput(stdout string, stderr string) string {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
if stdout == "" {
// Showing an explicit placeholder is clearer than an empty panel in the
// GUI: the user can tell that the command ran but produced no stream data.
stdout = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
@@ -1,4 +1,4 @@
package core package runner
import ( import (
"context" "context"
@@ -7,11 +7,155 @@ import (
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
) )
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
}
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir()
job := domain.Job{
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
SuccessExitCodes: "0,1",
}
record := RunJob(context.Background(), &job, "Schedule", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file to be written")
}
data, err := os.ReadFile(record.LogFile)
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{
"job_id: 99",
"job_name: Log Header Test",
"trigger: Schedule",
"state: OK",
"detail: ",
"command: " + job.Command,
"arguments: <empty>",
"success_exit_codes: 0,1",
"start_only: false",
"stdout:",
"stderr:",
} {
if !strings.Contains(content, want) {
t.Errorf("log file missing %q:\n%s", want, content)
}
}
// The time header must use the documented format.
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "time: ") {
ts := strings.TrimPrefix(line, "time: ")
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
}
break
}
}
}
func TestRunJobRecordFields(t *testing.T) {
job := domain.Job{
ID: 55,
Name: "Record Fields Test",
Command: echoCommand("record field check"),
}
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
if record.JobID != job.ID {
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
}
if record.JobName != job.Name {
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
}
if record.Trigger != "Schedule" {
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
}
if record.State != "OK" {
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
}
if record.LogFile == "" {
t.Error("LogFile should be a non-empty path")
}
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
}
if !strings.Contains(record.Output, "stdout:") {
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
}
if !strings.Contains(record.Output, "stderr:") {
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
}
}
func TestFormatOutput(t *testing.T) {
got := formatOutput("hello world", "some error")
want := "stdout:\nhello world\n\nstderr:\nsome error"
if got != want {
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
}
}
func TestFormatOutputEmptyStreams(t *testing.T) {
got := formatOutput("", "")
if !strings.Contains(got, "stdout:\n<empty>") {
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
}
if !strings.Contains(got, "stderr:\n<empty>") {
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
}
}
func TestLogArguments(t *testing.T) {
cases := []struct{ input, want string }{
{"", "<empty>"},
{" ", "<empty>"},
{"--flag", "--flag"},
{"--flag\r\n--value", "--flag\n--value"},
{"--flag\n--value", "--flag\n--value"},
}
for _, tc := range cases {
if got := logArguments(tc.input); got != tc.want {
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestSanitizeFileName(t *testing.T) {
cases := []struct{ input, want string }{
{"Hello Test", "Hello_Test"},
{"job-1_ok", "job-1_ok"},
{"!!!", "job"},
{"", "job"},
{"A/B:C", "A_B_C"},
}
for _, tc := range cases {
if got := sanitizeFileName(tc.input); got != tc.want {
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestRunJobWritesLogFile(t *testing.T) { func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 42, ID: 42,
Name: "Hello Test", Name: "Hello Test",
Command: echoCommand("hello from test"), Command: echoCommand("hello from test"),
@@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
} }
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 43, ID: 43,
Name: "Quoted Windows Command", Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
@@ -75,7 +219,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil { if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
job := Job{ job := domain.Job{
ID: 44, ID: 44,
Name: "Unquoted Windows Command", Name: "Unquoted Windows Command",
Command: scriptPath, Command: scriptPath,
@@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
} }
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := domain.Job{
ID: 45, ID: 45,
Name: "Separate Arguments", Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`, Command: `C:\Windows\System32\cmd.exe`,
@@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
} }
job := Job{ job := domain.Job{
ID: 46, ID: 46,
Name: "Accepted Exit Code", Name: "Accepted Exit Code",
Command: command, Command: command,
@@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
} }
job := Job{ job := domain.Job{
ID: 47, ID: 47,
Name: "Rejected Exit Code", Name: "Rejected Exit Code",
Command: command, Command: command,
@@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command = `C:\Windows\System32\cmd.exe` command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7" arguments = "/C\nexit /b 7"
} }
job := Job{ job := domain.Job{
ID: 48, ID: 48,
Name: "Start Only", Name: "Start Only",
Command: command, Command: command,
@@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
} }
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{ job := domain.Job{
ID: 49, ID: 49,
Name: "Missing Start Only", Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command", Command: "definitely-missing-gosentry-command",
@@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
t.Skip("Windows window visibility only") t.Skip("Windows window visibility only")
} }
invocation := jobInvocation(context.Background(), Job{ invocation := jobInvocation(context.Background(), domain.Job{
Command: `C:\Windows\System32\cmd.exe`, Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process", Arguments: "/C\necho visible direct process",
}) })
@@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) {
t.Skip("Windows window visibility only") t.Skip("Windows window visibility only")
} }
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"}) invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
if !invocation.hideWindow { if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window") t.Fatal("shell command should request hidden startup window")
} }
configureHiddenWindow(invocation.command) winproc.ConfigureHiddenWindow(invocation.command)
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow { if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
t.Fatal("expected shell command to be hidden") t.Fatal("expected shell command to be hidden")
} }
@@ -253,7 +397,7 @@ func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
} }
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`) command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
configureHiddenWindow(command) winproc.ConfigureHiddenWindow(command)
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""` want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
if command.SysProcAttr == nil { if command.SysProcAttr == nil {
+55
View File
@@ -0,0 +1,55 @@
package scheduler
import "time"
// Clock supplies the scheduler with the current time and a stream of ticks.
// Hiding both behind an interface lets tests drive the loop deterministically —
// firing ticks and controlling "now" — instead of waiting on the wall clock.
// Production uses RealClock.
type Clock interface {
// Now returns the current time. It is the value passed to the tick callback
// on each tick, so a fake can make due-evaluation deterministic.
Now() time.Time
// Ticks returns a channel that delivers a value on every scheduler tick. The
// scheduler reads it for the lifetime of the loop.
Ticks() <-chan time.Time
// Stop releases the resources backing Ticks. The scheduler calls it once when
// the loop exits.
Stop()
}
// RealClock is the production Clock: wall-clock time and a one-second ticker.
//
// A one-second cadence is accurate enough for cron-style desktop automation —
// five-field cron expressions have minute precision, while @every values may be
// shorter for testing and lightweight local tasks — and it keeps a single timer
// instead of one per job.
type RealClock struct {
ticker *time.Ticker
}
// NewRealClock returns a real clock. The underlying ticker is created lazily on
// the first Ticks call so a clock that is never started leaks nothing.
func NewRealClock() *RealClock {
return &RealClock{}
}
// Now returns the wall-clock time.
func (c *RealClock) Now() time.Time {
return time.Now()
}
// Ticks starts (once) and returns the one-second ticker channel.
func (c *RealClock) Ticks() <-chan time.Time {
if c.ticker == nil {
c.ticker = time.NewTicker(time.Second)
}
return c.ticker.C
}
// Stop halts the ticker if it was ever started.
func (c *RealClock) Stop() {
if c.ticker != nil {
c.ticker.Stop()
}
}
+56
View File
@@ -0,0 +1,56 @@
package scheduler
import (
"context"
"time"
)
// Scheduler is a thin timing loop. It owns no job or runtime state: on every
// clock tick it calls the injected tick function with the current time, and that
// function — the application service's RunDue — decides what, if anything, to
// run. Keeping all state and mutation in the service makes the service the sole
// writer (resolving the old shared-*[]Job data race) and reduces the scheduler
// to a loop that is trivially testable with a fake Clock.
type Scheduler struct {
clock Clock
tick func(now time.Time)
ctx context.Context
cancel context.CancelFunc
}
// NewScheduler builds a scheduler that calls tick on every Clock tick. The clock
// is injected so tests can drive the loop without the wall clock.
func NewScheduler(clock Clock, tick func(now time.Time)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
return &Scheduler{
clock: clock,
tick: tick,
ctx: ctx,
cancel: cancel,
}
}
// Start launches the loop on its own goroutine and returns immediately.
func (s *Scheduler) Start() {
go func() {
ticks := s.clock.Ticks()
defer s.clock.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticks:
// Pass the clock's notion of "now" rather than the tick value so a
// fake clock can control due-evaluation precisely.
s.tick(s.clock.Now())
}
}
}()
}
// Stop ends the loop. A tick already in progress finishes; no further ticks are
// delivered.
func (s *Scheduler) Stop() {
s.cancel()
}
+85
View File
@@ -0,0 +1,85 @@
package scheduler
import (
"sync"
"testing"
"time"
)
// fakeClock is a Clock whose ticks and "now" are driven by the test instead of
// the wall clock, so the scheduler loop can be exercised deterministically.
type fakeClock struct {
ticks chan time.Time
mu sync.Mutex
now time.Time
stopped bool
}
func newFakeClock(now time.Time) *fakeClock {
return &fakeClock{ticks: make(chan time.Time, 1), now: now}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *fakeClock) Stop() {
c.mu.Lock()
c.stopped = true
c.mu.Unlock()
}
func (c *fakeClock) isStopped() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.stopped
}
// fire advances the clock to t and delivers one tick.
func (c *fakeClock) fire(t time.Time) {
c.mu.Lock()
c.now = t
c.mu.Unlock()
c.ticks <- t
}
func TestSchedulerCallsTickWithClockNow(t *testing.T) {
clock := newFakeClock(time.Unix(0, 0))
got := make(chan time.Time, 1)
s := NewScheduler(clock, func(now time.Time) { got <- now })
s.Start()
defer s.Stop()
want := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
clock.fire(want)
select {
case now := <-got:
if !now.Equal(want) {
t.Errorf("tick now = %v, want %v", now, want)
}
case <-time.After(time.Second):
t.Fatal("scheduler did not call tick after a clock tick")
}
}
func TestSchedulerStopReleasesClock(t *testing.T) {
clock := newFakeClock(time.Now())
s := NewScheduler(clock, func(time.Time) {})
s.Start()
s.Stop()
// After Stop the loop exits and releases the clock via the deferred Stop.
deadline := time.Now().Add(time.Second)
for !clock.isStopped() {
if time.Now().After(deadline) {
t.Fatal("clock was not stopped after scheduler Stop")
}
time.Sleep(time.Millisecond)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package core package storage
import ( import (
"os" "os"
+19 -32
View File
@@ -1,4 +1,4 @@
package core package storage
import ( import (
"errors" "errors"
@@ -7,15 +7,16 @@ import (
"runtime" "runtime"
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
) )
type Store struct { type Store struct {
Paths Paths Paths Paths
Config Config Config domain.Config
} }
func OpenStore() (*Store, []Job, error) { func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths() paths, err := ResolvePaths()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
return writeYAML(s.Paths.ConfigPath, s.Config) return writeYAML(s.Paths.ConfigPath, s.Config)
} }
func (s *Store) SaveJobs(jobs []Job) error { func (s *Store) SaveJobs(jobs []domain.Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil { if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err return err
} }
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs}) return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
} }
func loadOrCreateConfig(paths Paths) (Config, error) { func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// Defaults favor a portable installation: settings and jobs begin next to the // Defaults favor a portable installation: settings and jobs begin next to the
// executable, while logs are grouped under a dedicated subdirectory. // executable, while logs are grouped under a dedicated subdirectory.
config := Config{ config := domain.Config{
JobsDir: ".", JobsDir: ".",
LogsDir: "logs", LogsDir: "logs",
MaxLogFiles: 100, MaxLogFiles: 100,
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
data, err := os.ReadFile(configPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return Config{}, err return domain.Config{}, err
} }
if err := yaml.Unmarshal(data, &config); err != nil { if err := yaml.Unmarshal(data, &config); err != nil {
return Config{}, err return domain.Config{}, err
} }
if strings.TrimSpace(config.JobsDir) == "" { if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root // Empty paths are treated as missing values rather than intentional root
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
return config, nil return config, nil
} }
func loadOrCreateJobs(path string) ([]Job, error) { func loadOrCreateJobs(path string) ([]domain.Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// The first run creates harmless sample jobs so a new user can immediately // The first run creates harmless sample jobs so a new user can immediately
// see scheduled and manual execution without inventing a command. // see scheduled and manual execution without inventing a command.
jobs := defaultJobs() jobs := defaultJobs()
normalizeJobs(jobs) normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs}) return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
} }
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var file JobsFile var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil { if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err return nil, err
} }
return file.Jobs, nil return file.Jobs, nil
} }
func normalizeJobs(jobs []Job) { func normalizeJobs(jobs []domain.Job) {
next := 1 next := 1
for index := range jobs { for index := range jobs {
job := &jobs[index] job := &jobs[index]
@@ -168,23 +169,9 @@ func normalizeJobs(jobs []Job) {
if job.SuccessExitCodes == "" { if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0" job.SuccessExitCodes = "0"
} }
if job.LastRun == "" { // Runtime state (last run, next run, status, output, activity) is no longer
job.LastRun = "Never" // part of Job. It is reconstructed each time the app starts via
} // domain.NewRuntime, so normalizeJobs only touches durable configuration.
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
// Runtime fields are reconstructed each time the app starts. Persisted run
// records live in log files, not in jobs.yaml, to keep the jobs file easy
// to review and edit by hand.
job.Logs = nil
} }
} }
@@ -222,8 +209,8 @@ func writeYAML(path string, value any) error {
return os.WriteFile(path, data, 0o644) return os.WriteFile(path, data, 0o644)
} }
func defaultJobs() []Job { func defaultJobs() []domain.Job {
return []Job{ return []domain.Job{
{ {
ID: 1, ID: 1,
Name: "Hello scheduler", Name: "Hello scheduler",
+181
View File
@@ -0,0 +1,181 @@
package storage
import (
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml")
original := []domain.Job{
{
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
SuccessExitCodes: "0,1",
StartOnly: true,
Enabled: true,
},
}
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
g, w := got[0], original[0]
if g.ID != w.ID {
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
}
if g.Name != w.Name {
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
}
if g.Folder != w.Folder {
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
}
if g.Schedule != w.Schedule {
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
}
if g.Command != w.Command {
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
}
if g.Arguments != w.Arguments {
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
}
if g.SuccessExitCodes != w.SuccessExitCodes {
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
}
if g.StartOnly != w.StartOnly {
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
}
if g.Enabled != w.Enabled {
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
}
// Runtime state no longer lives on Job at all (it moved to domain.JobRuntime),
// so there is nothing transient that could survive the save→load round-trip.
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != want.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
}
if got.LogsDir != want.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
}
if got.MaxLogFiles != want.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
}
if got.MaxLogAgeDays != want.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
}
if got.StartOnLogin != want.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
}
if got.KeepRunningInTray != want.KeepRunningInTray {
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
}
if got.NotifyOnFailure != want.NotifyOnFailure {
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
}
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// Blank enabled job gets default name, schedule, command, and exit codes.
// normalizeJobs only fills durable configuration now; runtime status is built
// separately by domain.NewRuntime.
if jobs[0].ID != 1 {
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
}
if jobs[0].Name != "Untitled job" {
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
}
if jobs[0].Schedule != "@every 1m" {
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
}
if jobs[0].SuccessExitCodes != "0" {
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
}
// Pre-set fields survive normalization unchanged.
if jobs[2].ID != 5 {
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
}
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
// Job carries only durable configuration; runtime state lives in
// domain.JobRuntime and is never marshalled. This guards against a future
// runtime field accidentally being added back onto Job with a yaml tag.
jobs := []domain.Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
},
}
data, err := yaml.Marshal(domain.JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}