Files
gosentry/docs/STANDARDS.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

9.3 KiB

GoSentry — Standards

Quality rules and intentional behavior for contributors. Package contracts live in ARCHITECTURE.md; test conventions in TESTS.md.

Code quality

  • Follow package contracts in ARCHITECTURE.md.
  • User-facing errors → dialog.ShowError or a History event, never a silent return.
  • Pure helpers → unit test in the same package.
  • Fixes with severity ≥ medium → regression test.
  • Documented intentional behavior → section below, not a backlog bug.
  • UI view constructors accept *app.Service; call app.Open() only from run.go.
  • No blocking file I/O under Service.mu. It is the lock the Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a log-directory scan, or a pass over every log header inside it makes a UI refresh wait on the disk. Mutate state under the lock, snapshot what the I/O needs, and run the I/O after mu.Unlock() — the way emit() already is. Store writes go through Service.deferSaveLocked and Store.PrepareSaveJobs / Store.PrepareSaveConfig, which take saveMu while mu is still held so writes still reach the file in the order their snapshots were taken; log cleanup and runner.SeedStats run from plain snapshots.
  • A size that must follow the theme is measured at build time, not written as a pixel constant. theme.Padding() and text metrics depend on the running app's theme, text size, and DPI, so a hand-tuned number is only correct for the one theme it was tuned against and clips under any other. Measure the real widget, or derive the value from the theme, in a named helper: rowOverlap (theme padding), captionColumnWidth and textColumnWidth (the widest of the actual strings), activityRowsHeight (the list's own row template). The same applies to a ratio computed from an absolute width — see initialSplitOffset. A raw pixel literal is left only where nothing about it tracks the theme, and says so in a comment.

Config file compatibility

There is no migration step: gosentry.json and jobs.json are read as-is, are meant to be hand-editable, and may have been written by an older version. A change to their shape has to stay compatible on its own.

  • A new Config field is tagged omitempty, and its zero value must mean the behavior that existed before the field was added — a file written without it keeps working unchanged. DefaultConfig() still sets the value explicitly.
  • A zero that carries meaning is not a missing field and must not be backfilled on load. See DefaultTimeoutSeconds in storage.loadOrCreateConfig and Job.TimeoutSeconds *int, where unset and 0 are different answers.
  • An unrecognised enum value reads as the default rather than an error, through one helper that every consumer shares (JobListView.IsCompact, ui.themeFor), and is normalized before being written back, so the file never gains a value no reader understands.
  • A renamed key keeps the old field on Config (tagged omitempty) purely so it can still be read. storage.loadOrCreateConfig converts it to the new field and clears it, so the retired key disappears on the next save. See Config.JobsDirConfig.JobsFile. Where the new field has a non-empty default, clear that default before unmarshalling, or "the file omits it" and "the file sets it" become indistinguishable and the conversion never runs.
  • Each of the three gets a test: the default in storage, the normalization in domain, and a round-trip through the real config file in app.

Intentional behavior (not bugs)

  • RunNow is allowed during global pause and for disabled jobs.
  • Selecting a jobs file that already exists loads it: its jobs replace the in-memory list, which is the only way the user can switch between job lists. A path with no file behind it receives the current jobs (rename/relocate). The switch is refused while a job is running, because adoption drops every runtime and a finishing run would then write its result onto whichever job inherited its ID.
  • Sequential mode runs jobs FIFO by order in jobs.json.
  • Scheduler tick is 1s — sub-second @every intervals are not supported.
  • Command timeout defaults to no timeout globally (Config.DefaultTimeoutSeconds = 0) and is overridable per job (Job.TimeoutSeconds *int: unset = inherit the global default, 0 = no timeout, positive = seconds). Neither zero may be normalized away on load — 0 is a value, not a missing field.
  • Config.MaxLogFiles and Config.MaxLogAgeDays of 0 mean "keep everything", not "unset". runner.CleanupLogs already treated <= 0 as "policy disabled"; app.validateConfig and the Settings form now accept 0 (only a negative count is rejected), and storage.loadOrCreateConfig no longer backfills 0 to 100 / 30 — a config written before either field existed still picks up the default because json.Unmarshal leaves an absent key holding whatever DefaultConfig() set, the same mechanism DefaultTimeoutSeconds relies on.
  • A StartOnly process is expected to outlive GoSentry. The option exists to launch something and let go of it, so the runner builds that invocation on context.Background(), not on the application's lifecycle context: quitting GoSentry (or cancelling a run) does not stop a process it started this way, and Service.Stop() reaches only jobs the runner is still waiting on. The uncancelable context is also what keeps os/exec from leaving a watcher goroutine per run — it only starts one when the context can be done, and StartOnly never calls Wait to end it.
  • History tab is session-only. JobRuntime.Logs exists only in memory for the current process. Log files on disk feed aggregate statistics via SeedStats only. See ARCHITECTURE.md.
  • History is capped and its columns only widen. The tab keeps the newest maxHistoryRows records and drops the oldest, the way maxJobLogs caps a job's own activity list — an app left in the tray records thousands of runs a day, each carrying the run's full captured output. Column widths are folded in one record at a time instead of rescanned from every row, so a column never narrows when a record ages out: the rows on screen were laid out against the wider value. A theme change is the one case that rescans, because every stored width was measured at the old text size.
  • Several tests share a coverage profile with another test on purpose, and a few functions sit at 0% on purpose. Both lists live in TESTS.md — check them before reporting a test as redundant or a coverage gap as an oversight.
  • KeepRunningInTray controls tray and close behavior. When enabled (the default), the app registers a system tray icon at launch, closing the window hides it, and autostart passes --start-in-tray. When disabled, no tray icon is registered at launch, closing the window quits the app, and autostart opens the main window. Toggling the setting in Settings updates close behavior and rewrites the autostart entry immediately; the tray icon itself follows the saved value only after a restart because Fyne has no API to add or remove it mid-session (see ROADMAP.md).
  • --start-in-tray defers to config. A stale autostart shortcut that still passes the flag does not hide the window when KeepRunningInTray is off.
  • JobRuntime.PendingRuns (the "queue" overlap policy's backlog) is capped at maxPendingRuns (10) and cleared on pause or disable. A job whose runs take longer than its interval stops accumulating backlog once the cap is hit — further overlaps are dropped like the "skip" policy until the backlog drains below the cap. SetGlobalPause(true) and SetEnabled(id, false) both zero the counter, so resuming or re-enabling a job never replays a deferred run for an occurrence that fired before the pause/disable. The details pane appends ", N queued" to the statistics line via DisplayStats whenever the count is non-zero.
  • Single-instance arbitration falls back to "start anyway" when the port is held by something else. acquireSingleInstance (singleinstance.go) binds 127.0.0.1:37653; if that fails and a dial to the same address does not answer as GoSentry either, startup continues rather than refusing to open because of an unrelated local listener. The consequence is deliberate but worth spelling out: two GoSentry processes can then run two schedulers against the same jobs.json and the same logs directory, each overwriting the other's saves. Atomic writes (writeFileAtomic) prevent a torn file from a concurrent write, but not one process's save clobbering the other's.
  • The single-instance channel is an unauthenticated localhost TCP port. Port 37653 accepts one command, "show", from any local process — including one running as a different user on a shared machine. This is a deliberate scope choice, not an oversight: the command only raises the existing window, so the impact of an unwelcome sender is a window popping up, not data exposure or control. Anything with a larger blast radius on that channel would need real authentication.

Out of scope

Larger or blocked work is tracked in ROADMAP.md (update check from GitHub releases, cron-table import/export, window size persistence, History column filters).