Files
gosentry/docs/ARCHITECTURE.md
T
mix 18da021526 chore: land the remaining low-severity items from the whole-project review
Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every
low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7,
7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11).

Behavioral fixes:
- Reassign duplicate job IDs in a hand-edited jobs.json instead of letting
  two jobs share one runtime, schedule entry, and SeedStats bucket.
- Disambiguate run-log file names that collide within the same second.
- Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an
  incremental integer mean, so it always matches the seeded-from-logs
  average instead of drifting from truncation error.
- Clean absolute paths in ResolveConfiguredPath so two spellings of the
  same jobs file do not trigger a spurious adoption.
- Report InstallDesktopIcon failures through ErrorOccurred instead of
  discarding them silently.
- Move settingsView's blocking AutostartStatus (PowerShell on Windows) off
  the UI thread.
- Give notify-timing.tsv its own extension so CleanupLogs no longer
  manages it as a run log.
- Replace the settingsView Save handler's second copy of validateConfig's
  rules with a bare parse, letting the Service's own error surface.

Cleanups:
- Delete collectActivity, the dead yaml tags on RunRecord, and the
  logArguments/LogArguments alias.
- Fold the two systemTrayRegistered/mainWindowHidden globals into one
  trayState instance Run owns and threads through Settings and the
  single-instance reveal path.
- Fix stale comments/docs: the frozen window-size restore claim, a
  reference to a renamed recordRun, README's "Pause all" and notification
  wording, the PowerShell quoting note for TESTS.md's coverage command,
  and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page.
- Document the single-instance fallback's consequence and the
  unauthenticated instance-channel port in STANDARDS.md; record the
  config-shim retirement plan in ROADMAP.md.

3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no
change needed for those three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 03:35:10 +03:00

306 lines
16 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
```
## Platform layer
GoSentry ships one binary per target OS. Platform-specific code is not a
workaround for missing cross-platform support — it **is** the cross-platform
strategy: shared interfaces and call sites, with OS-specific implementations
selected at **compile time** (`*_windows.go`, `//go:build linux`, and similar).
Runtime `runtime.GOOS` checks appear only for small UI details (see below), not
for autostart, file-manager integration, or command invocation.
Callers (`app.Service`, `ui`, `runner`) depend on the shared API; they do not
branch on the operating system.
| Package / file | Windows | Linux | Other (`!windows && !linux`) |
| --- | --- | --- | --- |
| `platform/autostart` | Startup-folder `.lnk` shortcut | XDG `~/.config/autostart/gosentry.desktop` | Stub — `Set` returns an error when enabled |
| `platform/desktop` | no-op | Installs `.desktop` + icon under XDG data home | no-op |
| `platform/filemanager` | `explorer` | `xdg-open` | Unsupported — `Open` returns an error |
| `platform/winproc` | `CREATE_NO_WINDOW` / `HideWindow` on child processes | no-op | no-op |
| `runner/invocation_*` | `cmd.exe /S /C` with Windows-safe quoting | `sh -c` | `sh -c` (same as Linux) |
**Why separate implementations are required**
- **Autostart** — each OS defines its own login startup mechanism (shortcut,
XDG Autostart, LaunchAgents on macOS). There is no portable API in Go, Fyne, or
the standard library; a third-party helper would still wrap the same per-OS
code behind an interface.
- **Opening a folder** — the desktop shell exposes no shared “reveal in file
manager” call; each platform invokes its registered handler (`explorer`,
`xdg-open`, `open` on macOS).
- **Command shell** — users expect OS-native semantics (`cmd.exe` batch files,
`%VAR%`, and path rules on Windows; POSIX `sh` on Linux). A single shell for
all platforms would break commands on one side or the other.
- **Hidden console window** — launching a child process from a GUI app can flash
a console on Windows only; Linux and macOS do not need equivalent flags.
**Deliberate platform choices (not OS API limits)**
- **Window and tray icons** — Fyne accepts icons on every platform, but Windows
renders the notification area and titlebar from multi-size `.ico` resources
(embedded via `packaging/windows/gosentry.rc`), while Linux StatusNotifier
trays scale better from a larger PNG. `ui/run.go` and `ui/tray.go` branch on
`runtime.GOOS` for asset selection only.
- **Sample job commands** in `storage/store.go` — demo `echo` lines differ only
because shell quoting rules differ; real jobs are user-authored per platform.
**Adding new platform code**
- Put OS integration in `src/platform/<name>/` with a small shared API, or use
`*_GOOS.go` files in the owning package when the surface is a single function
(as in `runner/invocation_*`).
- Do not scatter `runtime.GOOS` through `app.Service` or UI business logic.
- Unsupported platforms get an explicit stub (return an error or no-op) rather
than silently doing nothing — see `autostart_other.go` and
`filemanager_other.go`.
macOS autostart and file-manager handlers are not implemented yet; see
[ROADMAP.md](ROADMAP.md) for blocked or deferred cross-platform work (for
example window-maximized detection, which would need per-OS native calls).
## 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, and next-run times around it, applies the statistics
seeded from the new logs directory, 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.
Reading the new file and seeding its statistics both happen before `mu` is
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
running-job check is re-evaluated under the lock before anything is replaced.
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) under `mu`, then — after releasing it —
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
`JobRuntime`, which is never persisted. 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. When
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
off the entry launches the executable without that flag so the main window
opens after sign-in.
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 are
built on `context.Background()` instead — neither the timeout nor app shutdown
applies to them — and so measure launch latency only.
### 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, computed as `DurationSumMS / TimedRunCount` on every update rather than folded incrementally, so it never disagrees with the exact sum/count average `runner.aggregateLogStats` computes when seeding from logs |
| `MaxDurationMS` | longest recorded run |
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
`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 five files along these seams:
| File | Contents |
|------|----------|
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
The widgets hold no job state of their own: they read `jobsViewState`, which is
the only thing that reads the Service. The **selection is a job ID, not a row
index.** Every path that changes the job list replaces the state's snapshot —
create, delete, and edit from this view's own handlers, adopting a different
jobs file from the Service, which the view only learns about through the refresh
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
job now sits there, so the details pane would describe one job while the list
highlighted another. Rows are derived from the ID at render time
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
list's highlight at the selected job.
### `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:
| 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) |