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>
This commit is contained in:
2026-08-07 03:35:10 +03:00
parent bd7ebde68e
commit 18da021526
32 changed files with 357 additions and 167 deletions
+21 -4
View File
@@ -19,6 +19,13 @@ type Store struct {
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
// decisions that must run before app.Open(). On error it returns the built-in
// default.
//
// Despite the name, this can write: loadOrCreateConfig creates gosentry.json
// with defaults on first run, the same as OpenStore does moments later when
// app.Open() parses the now-existing file again. The double parse and the
// write-on-read are both harmless — the second read just sees the file the
// first one created — but worth knowing before adding a third startup path
// that also wants an early look at the config.
func PeekKeepRunningInTray() bool {
paths, err := ResolvePaths()
if err != nil {
@@ -207,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
func normalizeJobs(jobs []domain.Job) {
next := 1
seen := make(map[int]bool, len(jobs))
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
// IDs are assigned only when absent. Existing IDs stay stable because
// History and future log associations use them to identify jobs.
if job.ID <= 0 || seen[job.ID] {
// IDs are assigned only when absent or already claimed by an earlier job
// in this file — a hand-edited jobs.json can carry two entries with the
// same ID, which would otherwise share one runtime, one schedule-cache
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
// because History and future log associations use them to identify jobs.
job.ID = next
}
seen[job.ID] = true
if job.ID >= next {
next = job.ID + 1
}
@@ -241,7 +253,12 @@ func normalizeJobs(jobs []domain.Job) {
// apply the same rule to a path the user has typed but not yet saved.
func ResolveConfiguredPath(appDir string, path string) string {
if filepath.IsAbs(path) {
return path
// Cleaned so two spellings of the same file (forward vs. backslashes, a
// trailing separator) resolve to the same string. UpdateSettings compares
// this against Paths.JobsPath to decide whether the jobs file is changing,
// so an uncleaned path here could trigger a spurious adoption against the
// file the app is already using.
return filepath.Clean(path)
}
// Relative paths are resolved against the executable directory, not the
// process working directory. This matches ResolvePaths and keeps shortcuts,
+45
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -145,6 +146,50 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
}
}
// TestNormalizeJobsReassignsDuplicateIDs pins the fix for a hand-edited
// jobs.json carrying two entries with the same ID: without reassignment both
// would share one JobRuntime, one schedule-cache entry, and one SeedStats
// bucket, so editing or deleting either would silently affect both.
func TestNormalizeJobsReassignsDuplicateIDs(t *testing.T) {
jobs := []domain.Job{
{ID: 5, Name: "First"},
{ID: 5, Name: "Second"},
{ID: 5, Name: "Third"},
}
normalizeJobs(jobs)
seen := make(map[int]bool, len(jobs))
for _, job := range jobs {
if seen[job.ID] {
t.Fatalf("ID %d assigned to more than one job after normalization: %+v", job.ID, jobs)
}
seen[job.ID] = true
}
if jobs[0].ID != 5 {
t.Errorf("first occurrence should keep its ID: got %d, want 5", jobs[0].ID)
}
if jobs[1].ID == 5 || jobs[2].ID == 5 {
t.Errorf("later duplicates should be reassigned away from 5: got %d, %d", jobs[1].ID, jobs[2].ID)
}
}
// TestResolveConfiguredPathCleansAbsolutePaths pins the fix for two spellings
// of the same absolute path (forward vs. backslashes) resolving to different
// strings: UpdateSettings compares this against Paths.JobsPath as strings to
// decide whether the jobs file is changing, so an uncleaned path here could
// trigger a spurious adoption against the file already in use.
func TestResolveConfiguredPathCleansAbsolutePaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("backslash vs. forward-slash spellings of the same path are a Windows-only ambiguity")
}
got := ResolveConfiguredPath(`C:\app`, "C:/data/jobs.json")
want := ResolveConfiguredPath(`C:\app`, `C:\data\jobs.json`)
if got != want {
t.Errorf("forward-slash and backslash spellings resolved differently: %q vs %q", got, want)
}
}
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir()
paths := Paths{