Files
gosentry/docs/PROJECT_REVIEW_PLAN.md
T
mix eaa37a1192 docs: add the whole-project review action plan
Records the findings of a review of the whole codebase against the agenda in
REVIEW.md, with a suggested order and a model recommendation per item.

The project is in good health: the engine layering holds, the Service.mu
locking contract is obeyed, and the UI layout code observes the
measure-don't-hardcode rule with tests to match. The findings concentrate in
three places instead: paths that only surface after the app has run for a
while (unbounded History growth, PendingRuns surviving a pause), durability of
the JSON files (non-atomic writes, no shutdown path), and one Windows quoting
bug reproduced outside the build, where an argument ending in .exe hijacks
program-path detection for a .bat or .cmd job.

Also settles what happened to the two domain tests TESTS.md still documents:
5b0e6fe dropped them while adding two new rows to TESTS.md and leaving the old
rows in place, so the loss was accidental rather than the test-suite review's
doing. Only TestJobListViewIsCompact is worth restoring — the storage default
test already subsumes the other. Neither loss moved coverage; the 84.4% ->
84.0% dip is new 1.0.2 code, PeekKeepRunningInTray at 0%.

Temporary: delete once the items are done or moved to ROADMAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:19:45 +03:00

43 KiB
Raw Blame History

Whole-project review — action plan

Working document for the findings of the 2026-08-05 whole-project review, run against the agenda in REVIEW.md. It is not part of the permanent doc set: delete it once every item below is either done or moved to ROADMAP.md, the way TEST_REVIEW_PLAN.md was retired.

The rules the findings are judged against live in STANDARDS.md and ARCHITECTURE.md. Anything listed under "Intentional behavior" in STANDARDS is not reported as a bug; where this review disagrees with such an entry it says so explicitly as a challenge.

Baseline the review started from

Measured on the 1.0.2 tree (c8a4d31), MSYS2 UCRT64 / CGO on:

  • go vet ./... — clean.
  • go test -race ./... — all packages pass. src/ui alone takes 229 s; everything else finishes in under 8 s.
  • Engine coverage, merged profile over domain, storage, runner, scheduler, app: 84.0% (TESTS.md records 84.4% at the 2026-08-04 review). The 0.4 pp dip is not item 4.1 — it is new 1.0.2 code that arrived untested: storage.PeekKeepRunningInTray sits at 0%. It is a startup entry point like OpenStore and ResolvePaths, so if it is meant to stay uncovered it belongs in TESTS.md's "Functions deliberately at 0%" list, which currently does not name it.
  • 171 test functions in the tree. TESTS.md names 171 as well, but the sets do not match: two of the names it documents no longer exist, and two tests that do exist are undocumented (items 4.1, 4.2).
  • 77 Go files, ~10 500 lines including tests. Two direct dependencies.

Overall finding: the project is in good health. The engine layering (domainstorage/runner/schedulerappui) holds, the locking contract on Service.mu is stated and obeyed, and the UI layout code — usually the first thing to rot in a desktop app — is the strongest part of the codebase: sizes are measured from the theme, the helpers are named, and the geometry is pinned by tests that re-run under a scaled theme. The documentation set is unusually complete and, with the exceptions in §4, accurate.

The findings below are therefore mostly about the paths that only show up after the app has been running for a while (§3.1, §6.3), durability of the JSON files (§6.2), and one confirmed Windows quoting bug (§6.1).

Severity is used as REVIEW.md uses it: medium means it gets a regression test with the fix.


1. Architecture and project structure

1.1 Service.Store() is the hole in "the Service is the sole owner" — medium

service.go:170 hands callers the raw *storage.Store. Its own doc calls the surface transitional ("later phases narrow this"); the phase never came. Eight UI sites read Service-owned state straight through it:

Site Reads
jobs_view.go:60,61,64,66,79 Config.Paused, Config.JobListView, Config.OverlapPolicy, Config.DefaultTimeoutSeconds
settings_view.go:30 the whole Config, held as a live pointer for the session
run.go:73,78 Config.KeepRunningInTray, Config.Theme
mainwindow.go:92 Paths.LogsDir, from inside the notification path

This contradicts ARCHITECTURE ("the UI reads it through typed events, never through shared mutable state") and STANDARDS. It is not a live data race today, but only because of an invariant nothing writes down and nothing enforces: every writer of store.Config (UpdateSettings, SetGlobalPause, SetJobListView) happens to be reached from the Fyne main thread, so the unlocked UI reads are serialised with them by accident. One background writer — say, a future auto-reload of jobs.json, or moving log cleanup off the UI thread — turns all eight into races that -race will not catch, because no test drives them concurrently.

Fix: give the Service typed accessors that copy under mu (Config(), LogsDir()), convert the eight call sites, and either unexport Store() or reduce it to what the tests actually need.

1.2 SchedulerStateChanged is emitted and never consumed — low

events.go:38 documents it as "The UI uses it to update the pause/resume control and status text." No observer handles it: the single listener in mainwindow.go:67 type-asserts only RunRecorded, ErrorOccurred, and JobsLoaded. The Jobs toolbar keeps its own schedulerPaused copy and relabels the button inside its own tap handler (jobs_view.go:271).

It works because the tap handler is the only thing that can pause today. That is exactly the coupling the event bus exists to remove. Either consume the event and delete the local mirror, or delete the event and drop the claim.

1.3 The "exhaustive type-switch" the doc promises does not exist — low

The same comment block says the sealed Event interface means "a UI listener can exhaustively type-switch over them and the compiler will flag a new event type that a switch forgot to handle." Go has no exhaustiveness check on type switches, and the one listener does not even use a switch — it uses three independent assertions. The comment claims a safety property that is not there, which is how 1.2 went unnoticed. Reword it to say what sealing actually buys (observers cannot be handed an event type from outside the package).


2. Complexity against the size of the project

Nothing here is over-abstracted: the Clock interface, the runJob seam, and the autostart.Manager interface each have a real test seam or a real second implementation. The findings run the other way — code that is still there after its reason left.

2.1 Two full jobs.json rewrites per run that cannot change the file — medium

startRunLocked calls s.store.SaveJobs(s.jobs) on every run start and executeRun calls it again on every run finish. Neither function assigns to a single domain.Job field: everything they touch lives on JobRuntime, which is explicitly never persisted (runtime.go:5). Both calls therefore re-serialise and rewrite the identical bytes. SetGlobalPause (operations.go:182) does the same — its durable change is Config.Paused, saved separately by SaveConfig.

The cost is not only I/O. startRunLocked carries a five-line rollback block and a regression test (TestStartRunLockedRollbackOnSaveFailure) guarding a write that can never change the file's content, and the write happens under Service.mu (see 3.2). Removing the three calls removes the I/O, the rollback, and the failure mode at once.

Care needed: the review found no durable field written on these paths, but this should be re-verified against the domain.Job definition when the change is made, and TestStartRunLockedRollbackOnSaveFailure retired deliberately rather than left failing.

2.2 runner.logArguments is an alias of runner.LogArguments — low

invocation.go:68func logArguments(a string) string { return LogArguments(a) }. A leftover from exporting the function. Four call sites; inline them and delete it.

2.3 collectActivity always returns an empty slice at startup — low

mainwindow.go:30-37 builds an initialRuntimes map purely to feed collectActivity, which merges JobRuntime.Logs across jobs. History is session-only by design, so at construction time every Logs slice is empty and the result is always nil. The function's own comment says it is kept "for future history loading from log metadata" — a feature that is not on the ROADMAP.

Either delete the twelve lines, or record the placeholder in STANDARDS so the next reviewer does not re-report it. Its two unit tests are fine either way — they test the merge, not the caller.

2.4 The ROADMAP size table is stale — info

ROADMAP.md lists the files over the ~250-line guideline as of 1.0.0. Measured today:

File ROADMAP Now
src/app/operations.go 490 490
src/ui/jobs_view.go 355 361
src/ui/settings_view.go 277 304
src/storage/store.go 265 299
src/app/run.go 287 287
src/ui/history_view.go 282 282

Refresh the numbers when the split item is picked up; the trend is the point, not the individual figures.


3. Code quality

3.1 History grows without bound, and every run pays for it — medium

This is the most consequential finding in the review, because it only appears in the mode the app is designed to run in: left in the tray for days.

events in mainwindow.go:73 is appended to on every RunRecorded and never trimmed. Each entry is a full domain.RunRecord, including Output — the complete captured stdout and stderr of the run. Then, on every single event, refresh() runs:

  • resort() — copies the whole slice and sorts it (history_view.go:181);
  • setColumnWidths()historyColumnWidths(rows) — builds three slices of length n and calls fyne.MeasureText once per non-empty value in each of the Job, Detail, and Log columns (history_view.go:104).

So the per-run cost is O(n log n) sorting plus up to 3n text measurements on the UI thread, with n growing forever. One job on @every 10s produces ~8 600 records a day. JobRuntime.Logs is capped at 50 by maxJobLogs; the History slice — the one that actually accumulates — is not capped at all.

Fix in two parts: cap the History slice (a ring buffer, or a maxHistoryRows mirroring maxJobLogs), and stop rescanning every row for column widths on every event — widths only ever grow, so fold the new record into the current maxima instead of recomputing from scratch.

This is the one finding worth a measurement before and after, since STANDARDS already treats measured geometry as the standard of proof.

3.2 Blocking file I/O under Service.mu — medium

Service.mu is the lock the Fyne main thread takes on every Jobs() and Runtime() call — that is, on every UI refresh. Three things do file I/O while holding it:

  • executeRun calls runner.CleanupLogs — a directory scan plus up to MaxLogFiles unlinks — under mu, after every run.
  • Every SaveJobs / SaveConfig is a full JSON marshal and write under mu.
  • adoptJobsLocked calls runner.SeedStats under mu, reached from UpdateSettings on the UI thread.

None of it needs the lock: cleanup takes only the values already snapshotted into runEnv, and seeding only needs the job list. Move them outside the critical section, or snapshot and run them after mu.Unlock() the way the event emission already does.

SeedStats also opens every log file twice — once in readLogJobID and again in readLogHeader (seed.go:59,98) — and the first pass is not bounded by maxFiles, so it touches every .log in the directory. One pass returning (jobID, state, duration) halves the syscalls.

3.3 StartOnly leaks a goroutine per run and mis-owns the process — medium

runner.go:39 builds the fire-and-forget invocation with jobInvocation(ctx, …), which uses exec.CommandContext. After Start(), os/exec spawns a watcher goroutine that blocks until either Wait() returns or the context is done. StartOnly never calls Wait — that is the whole point — so the goroutine lives until the app exits, one per StartOnly run, and then calls Kill on a process whose handle startJobOnly already Released.

The kill is harmless in practice (a released handle makes it fail), but the leak is real and the ownership is the wrong shape: a job the runner explicitly stops waiting for should not be tied to the app's lifecycle context at all. Use exec.Command (or context.Background()) for the StartOnly branch and say in STANDARDS whether a started process is expected to outlive GoSentry.

3.4 InstallDesktopIcon swallows its error — low

platform.go:11if iconPath, err := desktop.InstallDesktopIntegration(…); err == nil { … }. The error is discarded with no dialog, no History event, and no log line. That is the silent return STANDARDS forbids. On Linux the visible symptom is a generic dock icon with no explanation. Emit ErrorOccurred.

3.5 RunDue keeps only the last start error — low

run.go:92startErr = err; continue. If two jobs fail to start on the same tick, the user sees one message. Join them (errors.Join) or emit one event per failure.

3.6 Settings re-implements validateConfig — low

settings_view.go:136-158 validates max log files, max log age, jobs file, logs dir, and default timeout with its own messages, before UpdateSettings validates the same five with different messages (operations.go:456). The UI genuinely needs the strconv parse; it does not need a second copy of the rules. Parse in the UI, validate in the Service, and show what the Service returns.


4. Documentation and comments

The doc set is accurate about design and rationale. What has drifted is the inventory.

4.1 Two documented tests were silently deleted — medium

TESTS.md lists TestJobListViewIsCompact and TestDefaultConfigUsesDetailedJobList under src/domain/config_test.go. Neither exists. Commit 5b0e6fe ("Wire KeepRunningInTray to runtime …") rewrote that file to hold TestAutostartArguments and TestResolveStartHidden instead of appending them, and the two older tests went with it.

This was not the test-suite review's doing, and it was not a decision. The 2026-08-04 review deleted exactly three tests — TestCleanupLogsKeepsFilesWithinAgeLimit, TestRunDueEmptyOverlapInheritsGlobal, and TestSameWindowsPathHandlesSpaces — each after measuring byte-identical coverage against a survivor whose assertions were a superset, and each recorded in TEST_REVIEW_PLAN.md and in the CHANGELOG. Its deletion commit 2ef18e7 never opened config_test.go; only 29ce94c (which created the two tests) and 5b0e6fe ever touched that file.

What settles it is what 5b0e6fe did to the documentation: it added the two new test rows to TESTS.md while leaving the two old rows in place, i.e. it documented the file as holding all four. The doc moved in the opposite direction from the code. A deliberate removal looks like 2ef18e7, which took its three rows out of TESTS.md in the same commit. Nothing in the commit message, the 1.0.2 CHANGELOG, or STANDARDS mentions the loss.

Accidental, however, does not mean both are worth having back. Only one was pulling weight:

  • TestJobListViewIsCompact — restore it. Its unique assertions are that "" and a differently-cased "Compact" both read as detailed. Neither holds anywhere else now: TestSetJobListViewNormalizesUnknownValue (app) covers only the unrecognised-value path through SetJobListView. The empty case is live rather than theoretical — loadOrCreateConfig does not normalize job_list_view the way it normalizes theme, so a config written before the field existed reaches IsCompact() empty and depends on exactly this behaviour. STANDARDS §Config file compatibility also requires it by name: "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." The domain one is the one that disappeared, so a rule STANDARDS calls mandatory is currently unenforced.
  • TestDefaultConfigUsesDetailedJobList — do not restore it; take its row out of TESTS.md instead. TestLoadOrCreateConfigCreatesDefaultsOnFirstRun (storage) already asserts got.JobListView == domain.JobListViewDetailed, through the real load path, which makes it a strict superset — and STANDARDS puts the default test in storage, not domain. Under TESTS.md principle 9 this is a legitimate deletion; it simply was never made deliberately.

Neither loss moved the number: IsCompact and DefaultConfig both measure 100% today, exercised through their callers. What was lost is an assertion, not statement coverage — which is the exact case TESTS.md principle 9 exists to name ("Identical coverage alone is not grounds for deletion").

The surviving test is recoverable verbatim from git show 5b0e6fe^:src/domain/config_test.go.

4.2 src/ui/notify_timing_test.go is undocumented — low

TestNotificationTimingFormatLine and TestAppendNotificationTimingLogWritesHeaderAndRow were added in 1.0.2 with no TESTS.md entry. Add the file's table.

4.3 The window-size comment describes a feature that is frozen — low

run.go:19: "later launches restore the last size from preferences." Nothing ever writes window.width / window.height — ROADMAP records the feature as deliberately frozen. The comment is wrong and the two prefs.FloatWithFallback reads are dead code that make it look implemented. See also 8.2.

4.4 README says "Pause all"; the button says "Disable auto" — low

README.md step 6 under Using The App. The control is labelled Disable auto / Enable auto (jobs_view.go:261).

4.5 A comment cites a function that no longer exists — low

jobs_view_helpers.go:11 refers to app.Service.recordRun. The function is executeRun.

4.6 README narrows when notifications fire — low

"…whenever a scheduled or manual run exits with a non-zero exit code." The condition is State == "Failed", which also covers timeouts and processes that failed to start.

4.7 The coverage command in TESTS.md does not run on the documented shell — info

TESTS.md gives the -coverpkg invocation in bash form. In the PowerShell environment DEVELOPMENT.md prescribes for Windows, PowerShell splits the comma-separated package list and the command fails with directory not found. It needs --% (or the whole flag quoted). Worth a note next to the command, since it is the one measurement the doc asks reviewers to reproduce.


5. Readability and maintainability

5.1 newJobsView is one 330-line constructor over shared mutable locals — medium

jobs_view.go:30-361. Twelve closures share jobs, runtimes, selected, selectedFolder, filteredJobs, listView, and schedulerPaused, and several of them patch two or three of those in sequence before calling refreshView. Understanding any one handler means reading all of them, because the invariant "selected indexes jobs, and the list's selection index indexes filteredJobs" is maintained by hand in five places.

ROADMAP already tracks the split. This review adds the reason it matters beyond line count: the state, not the length, is what makes it hard. Extracting a small jobsViewState struct with selectByID, applyFilter, and snapshot methods would shrink the file and make 5.2 impossible.

5.2 Selection is tracked by slice index, not by job ID — medium

selected is an index into a snapshot of the jobs slice. Every path that can change the slice — create, delete, filter — patches it explicitly. The path that replaces the whole list does not: adopting a different jobs file emits JobsLoaded plus a broad JobChanged, the observer calls refresh(), and refreshView calls updateDetails(selected) with an index from the previous list. The details pane then describes whichever job now happens to sit at that index, while the list's highlight is untouched.

Track the selection by Job.ID and resolve it to an index at render time.

5.3 operations.go mixes three jobs in one file — low

490 lines: the public mutating operations, the …Locked state helpers only they call, and the pure validators/normalizers. ROADMAP already names this as the clearest of the six splits; nothing to add except that it is still the worst overage.

5.4 The nested fyne.Do has no explanation — low

mainwindow.go:71 and 85 — the observer's body already runs inside fyne.Do, and the failure-notification block opens a second one. The nesting is deliberate (it defers the toast by one main-thread hop so UIQueuedAt can measure that hop for notify-timing.log), but nothing says so, and a reader's first instinct is to "simplify" it away and lose the instrumentation. The same block also calls appendNotificationTimingLog — a file open, stat, and write — on the UI thread.

Add the sentence that explains the nesting, and move the log append off the main thread.


6. Logical errors

6.1 Windows shell quoting picks the wrong program path — medium (reproduced)

quoteLeadingWindowsProgramPath walks the extension list .exe, .cmd, .bat, .com in list order and takes the first extension that appears anywhere in the string. It should take the extension that appears earliest, and only at a token boundary. When the program is a .bat or .cmd and any argument ends in .exe, the .exe in the argument is found first and the entire command line is treated as the program path.

Reproduced by running the function verbatim outside the build:

Input (job Command) Produced command line
C:\My Tools\run.bat D:\in.txt cmd.exe /S /C ""C:\My Tools\run.bat" D:\in.txt"
C:\My Tools\run.bat C:\Windows\System32\notepad.exe cmd.exe /S /C ""C:\My Tools\run.bat C:\Windows\System32\notepad.exe""
C:\Program Files\App\deploy.cmd D:\stage\setup.exe cmd.exe /S /C ""C:\Program Files\App\deploy.cmd D:\stage\setup.exe""
C:\dir.exexample\My Tool\run.bat cmd.exe /S /C "C:\dir.exexample\My Tool\run.bat" ✘ (never quoted)

The two ✘ rows in the middle hand cmd.exe a single quoted token that is not a file, so the run fails with a shell-level error the user cannot map back to their job. The last row is the mirror image: a .exe substring inside a directory name makes the check conclude the program path has no spaces, so a path that does need quoting is left bare.

Reachable through normal use: it applies whenever the command does not resolve as a direct executable path, which is what happens when the user types a whole command line into the Command field — the shape the field's own placeholder and the existing Joplin test fixture both demonstrate.

Fix: find the earliest extension match across all four extensions, and require the character after it to be a space or end-of-string. Regression test with the four rows above.

6.2 gosentry.json and jobs.json are written non-atomically — medium

storage.writeJSON is os.WriteFile — truncate, then write. A crash, a power loss, or the process exiting during the write leaves a truncated or empty file, and for jobs.json that is every job definition the user has.

The exposure is larger than it looks because of 2.1: SaveJobs runs twice per run, so the window is open constantly on a busy install. And Service.Stop() is never called — ui.Run has no shutdown path, and the tray's Quit item goes straight to a.Quit() (tray.go:72) — so quitting while a run is completing terminates the process mid-write with nothing to flush.

Fix: write to <name>.tmp in the same directory, Sync, then os.Rename over the target. Rename is atomic within a volume on both supported platforms. The same treatment is cheap for runner/logfile.go, though a torn log file costs much less than a torn jobs file.

Worth pairing with a Service.Stop() call on shutdown, which also makes the ctx cancellation the runner already implements actually reachable.

6.3 PendingRuns survives a pause and has no ceiling — medium

executeRun drains the queue with rerun := runtime.PendingRuns > 0 && current.Enabled && !s.paused. Nothing ever clears the counter. Two consequences:

  • Pause leaks a run. Pause the scheduler while a queue-policy job has a backlog, and the counter stays set. refreshNextRunLocked parks the job at "Scheduler paused" and the drain is skipped — correctly, and TestRunDueQueueDrainSkippedWhenPaused pins that. But after the user resumes, the stale counter is still there, and the next completed run of that job fires a deferred run that corresponds to an occurrence from before the pause. Disabling a job has the same shape: SetEnabled(false) does not clear it.
  • No ceiling. A job whose runs take longer than its interval increments PendingRuns on every missed occurrence forever. The job then runs back-to-back indefinitely, and there is no bound, no warning, and nothing in the UI that shows the queue depth.

Fix: clear PendingRuns in SetGlobalPause(true) and in SetEnabled(false), and cap it (a small constant, or the number of occurrences in one interval). Document the cap in STANDARDS next to the existing overlap-policy entry, and show the depth in the details pane if it is capped.

6.4 normalizeJobs never resolves duplicate IDs — low/medium

store.go:180 assigns an ID only when one is absent (job.ID <= 0). A hand-edited jobs.json — a workflow the project explicitly supports and README documents — with two entries carrying "id": 5 produces two jobs that share one JobRuntime entry, one schedule-cache entry, and one SeedStats bucket. findByIDLocked returns the first, so editing or deleting one silently targets the other; both runs write their state onto the same runtime.

Fix: track seen IDs during normalization and reassign the later duplicate, which is exactly what the existing next counter already computes.

6.5 Log file names collide within the same second — low

logfile.go:25 builds 20060102-150405_<name>.log. Two runs of the same job in the same second — a fast job re-run manually, or a queue drain of a sub-second command — write the same path and the second silently overwrites the first. SeedStats counts files, so the run history also under-counts. Add a disambiguating suffix when the path already exists.

6.6 Two different averages for the same history — low

updateStats keeps a truncating incremental mean ((avg*(n-1) + d) / n in integer arithmetic, so the truncation error compounds), while aggregateLogStats computes an exact sum / count. The same run history therefore reports a different average depending on whether it was seeded from logs at startup or accumulated live — and the two are mixed, because seeds are the starting values that updateStats then folds new runs into. Keep a running sum on JobRuntime and divide on read.

6.7 Absolute paths are not cleaned; relative ones are — low

ResolveConfiguredPath returns an absolute path verbatim and only Cleans the relative case. UpdateSettings decides whether the user is switching jobs files by comparing the resolved path to Paths.JobsPath as strings, so C:/data/jobs.json and C:\data\jobs.json read as two different files and trigger the adoption branch against the file the app is already using. filepath.Clean on both sides fixes it.

6.8 Missed occurrences during downtime are dropped — challenge, not a bug

adoptJobsLocked computes each job's first NextDue from time.Now(), so occurrences that fell while the app was closed never run and never appear in History. This is the right default for a desktop scheduler, but it is not written down anywhere — a user coming from cron with anacron habits will assume the opposite. Add it to STANDARDS §Intentional behavior.


7. Legacy code and migrations

The file-compatibility discipline STANDARDS describes is genuinely followed: Config.JobsDirConfig.JobsFile and the retired "default" theme value are both converted on load, cleared, and covered by a storage test. Nothing found that reads a shape the app cannot write. The findings are smaller.

7.1 domain.RunRecord carries dead yaml: tags — low

record.go tags all nine fields yaml:"…". Nothing serialises the type — History is session-only and log files are written as hand-rolled text — and there is no YAML dependency in go.mod. Leftover from an earlier format. Delete them, or convert to json: if the type is ever meant to be persisted.

7.2 Two compatibility shims with no retirement plan — low

Config.JobsDir (pre-0.15) and Theme == "default" (pre-1.0.1) are both read-only shims that rewrite the file into the current shape on the next save, so each one becomes dead the moment a user's config has been saved once by a current build. Neither has a note saying when it can go. Add "remove after " to each, or a single ROADMAP entry that retires both.

7.3 autostart exposes two public surfaces for one job — low

Each of the three implementations exports both the Manager methods and the bare SetAutostart / AutostartStatus functions the methods delegate to. Only the interface is used outside the package (plus the tests). Unexport the functions.


8. Stubs and claimed-but-unimplemented behavior

8.1 "Cleanup disabled" is documented and tested but unreachable — medium

CleanupLogs documents maxFiles <= 0 and maxAgeDays <= 0 as "policy disabled", and TestCleanupLogsZeroLimitsDisableBothPolicies pins it. The app can never produce that state: validateConfig rejects both as "must be a positive number" (operations.go:469), and loadOrCreateConfig backfills 0 to 100 / 30 on load (store.go:119). So a user cannot turn log cleanup off at all, by GUI or by hand-editing.

This is also inconsistent with DefaultTimeoutSeconds, where the project went to real trouble — a pointer type, a documented three-state table, a dedicated test — precisely so that a meaningful zero would survive.

Decide one way: either accept 0 as "unlimited" in validateConfig and stop backfilling it (documented in STANDARDS alongside the timeout rule), or delete the unreachable branch in CleanupLogs and its test. The first is the better outcome — "keep everything" is a real thing to want from a log retention setting.

8.2 Window-size preferences are read but never written — low

prefs.FloatWithFallback("window.width", …) in run.go:64 always returns the fallback because no code path writes those keys. Dead reads plus a comment that claims otherwise (4.3). Replace with the constants and leave a one-line pointer to the frozen ROADMAP item.

8.3 notify-timing.log shares the retention budget of run logs — low

appendNotificationTimingLog writes into logs_dir with a .log extension, so CleanupLogs counts it against MaxLogFiles and will delete it once it ages past MaxLogAgeDays. It is diagnostic instrumentation shipped in 1.0.2 for the "Faster Windows failure notifications" ROADMAP item, with no note on when it comes out. Give it a different extension (or a diagnostics/ subdirectory — CleanupLogs already skips directories) and add its removal to that ROADMAP entry.

Items 1.2 (SchedulerStateChanged) and 2.3 (collectActivity) also belong to this section; they are written up above.


9. GUI: crutches and layout

This section is close to clean, and that is the headline. The rule in STANDARDS — "a size that must follow the theme is measured at build time, not written as a pixel constant" — is actually observed: rowOverlap, captionColumnWidth, textColumnWidth, activityRowsHeight, and initialSplitOffset all derive from the theme or from measured text, and the ui tests assert the resulting geometry under two themes. The two raw numbers that remain (commandOutputScroll's 460×70 minimum and the +1 rounding allowance in activityRowsHeight) both carry a comment explaining why nothing about them tracks the theme. No layout crutches found.

The remaining items are small.

9.1 AutostartStatus runs PowerShell synchronously on the UI thread — low

settingsView is constructed eagerly during newMainView, and its constructor calls refreshAutostartStatus()svc.AutostartStatus(). On Windows with autostart enabled that reaches readShortcut (autostart_windows.go:124), which spawns powershell.exe and blocks on CombinedOutput() — the same PowerShell cold start ROADMAP measures at 700900 ms for notifications. It runs before the window is shown, and again on every toggle of either checkbox.

Given the project already measures and cares about startup time (PERFORMANCE.md), this is worth moving to a goroutine that posts its result back through fyne.Do, with the label showing "Checking…" meanwhile.

9.2 Two package-level mutable globals in tray.go — low

mainWindowHidden is justified and documented (Fyne exposes no Window.Visible). systemTrayRegistered is not: it is process-global state that no test can reset, and it exists only because applyTrayBehavior is called from two places. Passing it, or hanging both flags off a small struct owned by Run, removes the hidden coupling.

9.3 The activity list is refreshed twice per redraw — low

refreshView calls dp.logs.Refresh() immediately after updateDetails, which already ends in d.logs.Refresh() (jobs_view.go:91, jobs_view_details.go:103). Harmless, but it is the shape of duplicate-refresh bug that TestToolbarButtonRedrawsRowAndDetails was written to prevent.

9.4 The folder-filter rebuild is repeated three times — low

folderSelect.Options = folderOptions(jobs); folderSelect.Refresh() appears verbatim in the create, edit, and delete handlers. One rebuildFolders() closure beside refreshView.


10. Under-documented contentious decisions

REVIEW §8 asks whether a decision a future reader would question has its reasoning recorded. Most do — the platform layer, the timeout pointer, the details-pane width coupling, and the frozen window-size work are all model entries. These four are not.

  • Single-instance falls back to "start anyway." singleinstance.go:34 documents why it does not abort when port 37653 is held by something that is not GoSentry. It does not document the consequence: two GoSentry processes then run two schedulers against the same jobs.json and the same logs directory, each overwriting the other's saves. Combined with 6.2 that is a plausible way to lose the file.
  • The instance channel is an unauthenticated localhost TCP port. Any local process, including one running as another user on a shared machine, can send show. Low impact — the command only raises a window — but it is a design choice, not an accident, and it should say so.
  • The nested fyne.Do (5.4).
  • No catch-up after downtime (6.8).

11. Other improvement proposals

  • Startup parses gosentry.json twice, and PeekKeepRunningInTray (store.go:22) creates the file as a side effect of a function named "Peek", before OpenStore runs. Harmless today; a surprising name for a function with a write.
  • -race wall time is 4 minutes, 229 s of it src/ui. That is the single biggest tax on iteration in this repo and the reason the model recommendations below lean toward first-pass correctness.
  • scripts/test.bat prints / as UTF-8 in a file cmd.exe reads in the OEM code page, so the summary lines render as mojibake on a default Russian or US console. Use ASCII, or chcp 65001.
  • dist/ in the working tree holds a 1.0.1 binary and 130 sample run logs. Correctly gitignored, so this is only a note: the stale binary next to a 1.0.2 source tree is an easy thing to hand someone by accident.

Suggested order

Grouped so that each commit is independently reviewable and each medium finding lands with its regression test, per REVIEW.md's "What happens to the findings".

  1. 4.1 — restore TestJobListViewIsCompact, retire the other row. Smallest, and it restores an enforcement STANDARDS calls mandatory. Do it first so the rest of the work runs against a suite that is honest about itself. TESTS.md changes in the same commit: add the restored test back, drop the TestDefaultConfigUsesDetailedJobList row, add the notify_timing_test.go table (4.2), and add PeekKeepRunningInTray to the deliberate-0% list if that is the intent.
  2. 6.1 — the Windows quoting bug. Self-contained, one function, CGO-free package, four-row table test already written out above.
  3. 6.2 — atomic writes, plus a Service.Stop() on shutdown. Touches one helper and one call site; protects everything else.
  4. 2.1 — drop the three no-op SaveJobs calls, and retire TestStartRunLockedRollbackOnSaveFailure with the rollback it guards. Best done after 6.2, so the durability question is already settled and this is purely a removal.
  5. 6.3 — PendingRuns lifecycle and cap, with STANDARDS updated alongside.
  6. 3.1 — cap History and stop rescanning column widths. The biggest behavioural win; needs a before/after measurement, and it is in ui, so it is the item with the slowest feedback loop.
  7. 3.2, 3.3 — I/O off mu, StartOnly context. Related concurrency cleanups; one commit each.
  8. 8.1 — decide what a zero retention limit means, and make the code, the validator, and STANDARDS agree.
  9. 1.1 — typed Service accessors, retire Store(). Mechanical once decided, but it touches eight UI sites and is best done when nothing else is in flight. Rolls up 1.2, 1.3 and 7.3.
  10. 5.1, 5.2 — the Jobs view state extraction, folded into the ROADMAP file-split item rather than done separately. 5.2 is a real defect, so if the split slips, fix the selection-by-ID part on its own.
  11. The remaining low items (2.2, 2.3, 3.43.6, 4.34.7, 6.46.7, 7.17.3, 8.2, 8.3, 9.19.4, §10, §11) as a small number of themed cleanup commits.

CHANGELOG entries are needed for 6.1, 6.2, 6.3, 3.1, 3.3, and 8.1 — those change shipped behavior. The rest is internal.


Which model to use

For running these items in Claude Code. As in the retired test-suite plan, the deciding factor is not task size — it is that the feedback loop is slow: the ui package needs the MSYS2 UCRT64 toolchain with CGO on, and src/ui alone took 229 s in this review's go test -race ./... run — every other package in the tree finished in under 8 s. A model that gets an edit right on the first pass is worth more than a faster one that needs a second four-minute build to discover it was wrong.

Item Model Why
1 — restore one test, sync TESTS.md Haiku 4.5 (claude-haiku-4-5) The test is recoverable verbatim from git show 5b0e6fe^:src/domain/config_test.go, and both judgment calls — that the removal was accidental, and that only one of the two is worth restoring — are already settled in §4.1. What is left is a paste plus four doc-table edits, in domain, which runs in ~2 s. Nothing to weigh.
2 — Windows quoting Sonnet 5 (claude-sonnet-5) The defect and the four expected outputs are already pinned in this document, so the judgment is made; writing the earliest-match-at-a-boundary scan and its table test is careful execution work. runner needs no CGO and its Windows-gated test file runs in seconds.
3 — atomic writes + Service.Stop() Sonnet 5 Temp-file-then-rename is a known pattern; the only real decisions (same directory, Sync before rename, what to do with a leftover .tmp) are stated. The Stop() wiring in run.go is two lines.
4 — remove the no-op saves Opus 5 (claude-opus-5) This one is a judgment call disguised as a deletion. It requires re-deriving, against the current domain.Job, that no durable field changes on those paths — and being willing to say "actually one does" instead of deleting the safety net. It also retires an existing regression test, which is the sort of change that should not be made by a model optimising for completing the task.
5 — PendingRuns lifecycle and cap Opus 5 Interacting state across pause, disable, drain, and the tick loop, with three existing queue tests that must keep passing and a cap whose value is a design decision, not a lookup. app is CGO-free, but the reasoning is the cost here, not the build.
6 — History cap + incremental column widths Opus 5 The item with the worst feedback loop (in ui, 229 s per attempt) and the one where a plausible-looking fix can be wrong: widths must never shrink below what is on screen, and the cap interacts with the sort toggle and the cached rows snapshot that TestHistorySortToggleKeepsRowsInSync exists to protect. Fast mode (/fast) is worth enabling here specifically, since the wait is real.
7 — I/O off mu, StartOnly context Opus 5 Lock-scope changes are exactly where a confident-but-wrong edit is expensive: moving CleanupLogs out from under mu must not move the snapshot reads with it. The StartOnly half requires knowing why exec.CommandContext keeps a goroutine alive without Wait — reasoning about the standard library's internals, not about this repo.
8 — zero retention limits Sonnet 5 Once the direction is chosen (accept 0 as unlimited, per §8.1), the change is a validator branch, a load branch, a STANDARDS entry, and two tests, all in CGO-free packages. If the decision goes the other way — deleting the branch and its test — it is smaller still.
9 — typed Service accessors Sonnet 5 Eight mechanical call-site conversions plus two new accessors. The design is settled in §1.1; the work is breadth, not depth. Half the sites are in ui, so budget one slow verification run rather than several.
10 — Jobs view state extraction Opus 5 The ROADMAP already says why: a split reads as pure movement while quietly dropping a function, and this one has to break up a constructor rather than move whole functions. The selection-by-ID defect has to survive the move as a fix, not be re-introduced by it.
11 — the low-severity cleanups Sonnet 5, or Haiku 4.5 for the doc-only ones Each is small and independently verifiable. Group the CGO-free ones (domain, storage, runner, app) into one pass and the ui ones into another, so the 229 s build is paid once rather than per item.

Two notes on this table:

  • Sonnet 5 is the reasonable single choice if you would rather not switch models per item: items 47 and 10 are the only ones that really reward the step up, and of those only 6 and 7 are likely to go wrong quietly. Sonnet 5's introductory pricing runs through 2026-08-31 ($2/$10 per MTok vs $3/$15 after), against Opus 5's $5/$25.
  • Fast mode is available on Opus 5 (toggle with /fast). It is the same model with higher output throughput, not a downgrade, but it bills at $10/$50, so it only pays for itself when you are actually waiting on output. On this plan that is item 6 — and, if you batch them, the ui half of item 11.