ca2a8c8aa7
Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now means "keep everything" end to end. runner.CleanupLogs already treated <= 0 as disabled; validateConfig, the Settings form, and loadOrCreateConfig's backfill were the only things making that state unreachable. Phase 9 (1.1, rolling up 1.2, 1.3, 7.3): added Service.Config() and Service.Paths(), copying under mu, and converted every UI site that read Service state through the raw *storage.Store returned by Store() (now removed). jobs_view's pause control is now driven by refreshView reading svc.Config().Paused on every event instead of only mirroring its own tap handler, which makes it an actual consumer of SchedulerStateChanged. mainwindow's event listener is a real type switch, and events.go's doc comment no longer claims a compiler exhaustiveness check Go doesn't have. Unexported the redundant SetAutostart/AutostartStatus package functions in platform/autostart now that only the Manager methods are used outside the package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.2 KiB
8.2 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.ShowErroror a History event, never a silentreturn. - 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; callapp.Open()only fromrun.go. - No blocking file I/O under
Service.mu. It is the lock the Fyne main thread takes on everyJobs()andRuntime()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 aftermu.Unlock()— the wayemit()already is. Store writes go throughService.deferSaveLockedandStore.PrepareSaveJobs/Store.PrepareSaveConfig, which takesaveMuwhilemuis still held so writes still reach the file in the order their snapshots were taken; log cleanup andrunner.SeedStatsrun 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),captionColumnWidthandtextColumnWidth(the widest of the actual strings),activityRowsHeight(the list's own row template). The same applies to a ratio computed from an absolute width — seeinitialSplitOffset. 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
Configfield is taggedomitempty, 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
DefaultTimeoutSecondsinstorage.loadOrCreateConfigandJob.TimeoutSeconds *int, where unset and0are 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(taggedomitempty) purely so it can still be read.storage.loadOrCreateConfigconverts it to the new field and clears it, so the retired key disappears on the next save. SeeConfig.JobsDir→Config.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 indomain, and a round-trip through the real config file inapp.
Intentional behavior (not bugs)
RunNowis 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
@everyintervals 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.MaxLogFilesandConfig.MaxLogAgeDaysof 0 mean "keep everything", not "unset".runner.CleanupLogsalready treated<= 0as "policy disabled";app.validateConfigand the Settings form now accept 0 (only a negative count is rejected), andstorage.loadOrCreateConfigno longer backfills 0 to 100 / 30 — a config written before either field existed still picks up the default becausejson.Unmarshalleaves an absent key holding whateverDefaultConfig()set, the same mechanismDefaultTimeoutSecondsrelies on.- A
StartOnlyprocess is expected to outlive GoSentry. The option exists to launch something and let go of it, so the runner builds that invocation oncontext.Background(), not on the application's lifecycle context: quitting GoSentry (or cancelling a run) does not stop a process it started this way, andService.Stop()reaches only jobs the runner is still waiting on. The uncancelable context is also what keepsos/execfrom leaving a watcher goroutine per run — it only starts one when the context can be done, andStartOnlynever callsWaitto end it. - History tab is session-only.
JobRuntime.Logsexists only in memory for the current process. Log files on disk feed aggregate statistics viaSeedStatsonly. See ARCHITECTURE.md. - History is capped and its columns only widen. The tab keeps the newest
maxHistoryRowsrecords and drops the oldest, the waymaxJobLogscaps 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.
KeepRunningInTraycontrols 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-traydefers to config. A stale autostart shortcut that still passes the flag does not hide the window whenKeepRunningInTrayis off.JobRuntime.PendingRuns(the "queue" overlap policy's backlog) is capped atmaxPendingRuns(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)andSetEnabled(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 viaDisplayStatswhenever the count is non-zero.
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).