84e81371c1
Four documents asserted things the code contradicts. ARCHITECTURE's component diagram had the UI calling the autostart Manager directly. It does not, and must not: src/ui holds no reference to the package at all — Settings reads svc.AutostartStatus(), like everything else it reads. The edge is folded into the existing ui→Service one, so the diagram no longer draws the exception to the project's own rule. platform/desktop was described in both ARCHITECTURE and DEVELOPMENT as a "display-scale helper". It installs the .desktop entry and icon under XDG data home; there is no scale helper in it. The ~250-line file guideline was written as though the jobs_view and settings_view splits had settled it. Both files are over it again and history_view.go has never been split, so the guideline is now stated as the target it is, with the current state named rather than implied. STANDARDS pointed at a "CI coverage gate" item that ROADMAP does not have, while omitting the two it does. README's gosentry.json sample was three keys short of what the app writes on first run — default_timeout_seconds, theme and job_list_view — which made the one file the user is invited to hand-edit the least accurate thing in the document. The sample is now the real default (verified by marshalling DefaultConfig), with the keys explained, including why a zero timeout is written out and an unset one is not. The per-job overrides for overlap policy and timeout were undocumented despite being in the job dialog, and the feature list had not caught up with the timeout, the theme, or the compact job list. Version numbers in example output paths are now <version>, matching how the CI section already wrote them, so they cannot go stale again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
11 KiB
Markdown
228 lines
11 KiB
Markdown
# GoSentry Architecture
|
|
|
|
This document shows the current component interaction model. GoSentry is a
|
|
single desktop process: the GUI, application service, scheduler, storage, and
|
|
command runner live in one application. They communicate through typed events
|
|
and well-defined interfaces rather than shared mutable state.
|
|
|
|
## Package Map
|
|
|
|
```
|
|
cmd/gosentry entry point — starts the UI
|
|
src/
|
|
domain/ pure value types: Job, Config, RunRecord, Schedule, JobRuntime
|
|
app/ Service — sole owner of job/runtime state; emits typed Events
|
|
scheduler/ pure timing loop; calls app.Service.RunDue on every tick
|
|
runner/ shell command execution + log file writing + cleanup
|
|
storage/ JSON persistence (gosentry.json, jobs.json)
|
|
platform/
|
|
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
|
|
desktop/ desktop entry + icon under XDG data home (Linux only)
|
|
filemanager/ open a folder in the desktop file manager
|
|
winproc/ hidden-window startup flags (Windows only)
|
|
ui/ Fyne windows, tabs, and dialogs; reads service via Events
|
|
```
|
|
|
|
## Component Diagram
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
user["Desktop user"]
|
|
ui["src/ui - Fyne windows, tabs, dialogs"]
|
|
svc["src/app Service - sole owner of job + runtime state"]
|
|
store["src/storage Store - JSON config and jobs"]
|
|
sched["src/scheduler Scheduler - pure timing loop"]
|
|
runner["src/runner - shell command execution"]
|
|
autostart["src/platform/autostart Manager - Windows shortcut / Linux XDG"]
|
|
config["gosentry.json - application settings"]
|
|
jobs["jobs.json - job definitions"]
|
|
logs["logs_dir - per-run command output logs"]
|
|
shell["Platform shell - cmd.exe /C or sh -c"]
|
|
|
|
user -->|"edits jobs, settings, runs commands"| ui
|
|
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
|
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
|
|
store -->|"read/write"| config
|
|
store -->|"read/write"| jobs
|
|
|
|
svc -->|"Start(RunDue)"| sched
|
|
sched -->|"RunDue(now)"| svc
|
|
svc -->|"RunJob"| runner
|
|
runner -->|"execute command"| shell
|
|
runner -->|"write stdout/stderr log"| logs
|
|
runner -->|"RunRecord"| svc
|
|
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
|
|
ui -->|"display jobs, history, status"| user
|
|
|
|
svc -->|"Set / Status via Manager"| autostart
|
|
```
|
|
|
|
## Main Flows
|
|
|
|
1. Startup:
|
|
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
|
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
|
|
events, builds the main window, and calls `Service.Start` to begin the
|
|
scheduler loop. On every launch the service seeds per-job run-time statistics
|
|
from existing log files so the details panel reflects accumulated history
|
|
immediately (see §Statistics below).
|
|
|
|
2. Editing settings or jobs:
|
|
The UI calls mutating methods on `app.Service` (e.g. `CreateJob`,
|
|
`UpdateJob`, `UpdateSettings`). The Service validates the request, updates
|
|
its in-memory state, persists through `storage.Store`, and emits a typed
|
|
`Event`. The UI's observer receives the event and refreshes the relevant
|
|
widget on the main thread via `fyne.Do`.
|
|
|
|
`UpdateSettings` has one extra step: when the configured jobs file changes
|
|
and a file already exists at the new path, that file is authoritative. The
|
|
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
|
map, schedule cache, next-run times, and log-seeded statistics around it, and
|
|
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
|
|
receives the current jobs instead. Adoption drops all runtime state, so it is
|
|
refused while a job is running.
|
|
|
|
3. Scheduled run:
|
|
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
|
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
|
|
due, marks each as running, and launches `runner.RunJob` in a goroutine.
|
|
|
|
4. Manual run:
|
|
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
|
|
exists, is not already running, and (in sequential mode) that no other job is
|
|
running, then executes `runner.RunJob` with the `Manual` trigger. Manual runs
|
|
are allowed even while the scheduler is globally paused.
|
|
|
|
5. Command execution:
|
|
`runner.RunJob` builds the platform-specific invocation, executes the
|
|
command through the platform shell under the caller-supplied timeout, captures
|
|
stdout and stderr, writes one timestamped `.log` file, and returns a
|
|
`domain.RunRecord` containing
|
|
`DurationMS` (wall-clock milliseconds from start to finish; for `StartOnly`
|
|
fire-and-forget jobs it measures launch latency — the time to spawn the
|
|
process — since there is no exit to wait for).
|
|
|
|
6. History update:
|
|
When a run goroutine completes, `Service` updates the job's runtime
|
|
(including the statistics aggregate), saves JSON, triggers log cleanup, and
|
|
emits `RunRecorded`. The UI observer appends the record to the History tab.
|
|
History rows exist only for the current process session; restarting the app
|
|
clears the table (aggregate stats in the details panel are still seeded from
|
|
log files).
|
|
|
|
7. Autostart:
|
|
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
|
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
|
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
|
|
entries pass `--start-in-tray`.
|
|
|
|
8. Error surfacing:
|
|
Background errors (failed JSON saves, cleanup errors) are emitted as
|
|
`ErrorOccurred` events and displayed in the UI status area, rather than
|
|
being silently discarded.
|
|
|
|
## Key Domain Concepts
|
|
|
|
### Per-job overlap policy
|
|
|
|
`domain.Job` carries an `OverlapPolicy` field (`json:"overlap_policy,omitempty"`).
|
|
When non-empty it overrides the global `Config.OverlapPolicy` for that job alone.
|
|
Empty means inherit the global default. `app.Service.RunDue` resolves the
|
|
effective policy per job: it uses `job.OverlapPolicy` when set, otherwise falls
|
|
back to `store.Config.OverlapPolicy`. `normalizeJob` in `app/operations.go` leaves
|
|
the field empty on new jobs so the inherit semantics are preserved.
|
|
|
|
Under the `"queue"` policy, each occurrence that fires while a run is still
|
|
in flight increments `JobRuntime.PendingRuns`. When the current run finishes,
|
|
`executeRun` drains the counter by starting one deferred run per completion until
|
|
`PendingRuns` reaches zero.
|
|
|
|
### Per-job command timeout
|
|
|
|
`domain.Job` carries a `TimeoutSeconds *int` field
|
|
(`json:"timeout_seconds,omitempty"`), following the same inherit pattern as the
|
|
overlap policy. It is a **pointer** because the setting has three states that
|
|
must stay distinguishable on disk:
|
|
|
|
| `Job.TimeoutSeconds` | jobs.json | Meaning |
|
|
| --- | --- | --- |
|
|
| `nil` | field absent | inherit `Config.DefaultTimeoutSeconds` |
|
|
| `0` | `"timeout_seconds": 0` | no timeout, does **not** inherit |
|
|
| `> 0` | `"timeout_seconds": 45` | per-job limit in seconds |
|
|
|
|
The global `Config.DefaultTimeoutSeconds` (default **0**, i.e. no timeout) is
|
|
written unconditionally — no `omitempty` — for the same reason: `0` there is a
|
|
deliberate choice, not a missing value, and `storage.loadOrCreateConfig` must not
|
|
normalize it away. `app.Service.effectiveTimeout`
|
|
resolves the effective duration under `mu` and `startRunLocked` snapshots it into
|
|
`runEnv.timeout`. `runner.RunJob(ctx, job, trigger, logsDir, timeout)` takes the
|
|
resolved duration as an argument, so the runner stays ignorant of the global
|
|
config: a positive duration applies the timeout via `context.WithTimeout` and
|
|
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
|
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
|
|
the untimed context and so measure launch latency only, unaffected by the run
|
|
timeout.
|
|
|
|
### Run-time statistics
|
|
|
|
`domain.JobRuntime` holds a rolling aggregate updated after each run:
|
|
|
|
| Field | Meaning |
|
|
|-------|---------|
|
|
| `RunCount` | total runs recorded |
|
|
| `FailCount` | runs that exited non-zero |
|
|
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
|
|
| `AvgDurationMS` | mean over all runs with a recorded duration |
|
|
| `MaxDurationMS` | longest recorded run |
|
|
|
|
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
|
|
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
|
log file header alongside the existing `state` line.
|
|
|
|
On startup, `runner.SeedStats` scans log files (matched primarily by the
|
|
`job_id` header, with a sanitized-name filename fallback for legacy logs,
|
|
bounded by `Config.MaxLogFiles`) and folds the parsed `state`/`duration`
|
|
headers into a `runner.SeededStats` map. `NewService` applies those seeds to
|
|
the runtime map before the first scheduler tick, so the details panel shows
|
|
accumulated run history immediately after a restart.
|
|
Older log files that pre-date the `duration` header are tolerated: the run is
|
|
counted but the timing is skipped.
|
|
|
|
`JobRuntime.Logs` (per-run `RunRecord` entries shown in the History tab) is
|
|
**session-only**: it is not written to `jobs.json` and is not rebuilt from
|
|
`.log` files on startup. Log files on disk feed aggregate counters via
|
|
`SeedStats` only. See [STANDARDS.md](STANDARDS.md).
|
|
|
|
### Persisted global pause
|
|
|
|
`domain.Config` carries a `Paused bool` field (`json:"paused,omitempty"`).
|
|
`app.Service.SetGlobalPause` writes the new value into `store.Config` and calls
|
|
`SaveConfig`, so the paused state survives a restart. `NewService` initialises
|
|
`s.paused` from `store.Config.Paused` and applies the paused next-run text to
|
|
all runtimes before the first tick, ensuring the UI shows the correct state from
|
|
the moment the window opens.
|
|
|
|
### `jobs_view.go` file structure
|
|
|
|
The size guideline for a file in this project is ~250 lines. `src/ui/jobs_view.go`
|
|
is split across three files along these seams; the view file itself has grown
|
|
back over the guideline since, and is the next candidate if it grows further:
|
|
|
|
| File | Contents |
|
|
|------|----------|
|
|
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
|
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
|
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
|
|
|
### `settings_view.go` file structure
|
|
|
|
`src/ui/settings_view.go` is split across three files the same way, once its
|
|
own size passed the guideline. `src/ui/history_view.go` is over it too and has
|
|
not been split:
|
|
|
|
| File | Contents |
|
|
|------|----------|
|
|
| `settings_view.go` | `settingsView` — field construction, save, load, validate; the Theme label translation helpers |
|
|
| `settings_view_layout.go` | `newSettingsLayout`, `settingsSection`, `settingsRow` — the two-column arrangement and the button row |
|
|
| `settings_view_helpers.go` | Pure helpers — `fyneVersion`, `mustParseURL`, `settingsFolderPath`, `openFolder`, `chooseFile`/`chooseJSONFile`, `chooseFolder` (`chooseFile` also backs `job_dialog.go`'s command browser) |
|