Mechanical moves only — operations, store, history_view, and settings_view
are now split along their existing seams so every file stays within the 250+20%
guideline. Document the new layout in ARCHITECTURE.md and close the ROADMAP item.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
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>
Phase 7 of the whole-project review (findings 3.2 and 3.3).
Service.mu is the lock the Fyne main thread takes on every Jobs() and
Runtime() call, so anything blocking inside it makes a UI refresh wait on
the disk. Three things did:
- Every SaveJobs/SaveConfig was a marshal, fsync, and rename under mu.
Writes are now prepared under the lock (Store.PrepareSaveJobs /
PrepareSaveConfig snapshot the payload and target path) and run after
it is released. deferSaveLocked takes saveMu while mu is still held, so
writes still reach the file in the order their snapshots were taken and
an older snapshot can never land on top of a newer one.
- executeRun ran runner.CleanupLogs under mu after every run. It needs
only the values already snapshotted into runEnv, so it now runs after
the unlock — including when the job is gone, since the run still wrote
a log file that retention covers.
- adoptJobsLocked ran runner.SeedStats under mu, reached from
UpdateSettings on the UI thread. Seeding moved out into
applySeededStatsLocked; UpdateSettings now reads the new jobs file and
seeds its statistics before taking the lock, and re-checks the
"no jobs-file switch while running" guard once it has it.
SeedStats also opened every log file twice — once to find the job, again
to read the result. readLogSummary reads job_id, state, and duration in
one pass, so each log is opened once.
StartOnly runs were built with exec.CommandContext on the app's lifecycle
context. os/exec keeps a watcher goroutine alive until Wait returns or the
context is done, and StartOnly never calls Wait, so one goroutine leaked
per run and would then try to kill a process whose handle startJobOnly had
already released. The invocation now uses context.Background(), whose nil
Done channel means no watcher is started at all.
Regression tests: TestRunJobStartOnlyLeavesNoContextWatcher (fails with 5
leaked goroutines on the old code), TestConcurrentJobOperationsLeaveTheFileMatchingMemory,
and TestUpdateSettingsSeedsAdoptedJobsFromLogs. STANDARDS gains the
no-I/O-under-mu rule and the "a StartOnly process outlives GoSentry" entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements items 1-3 of the whole-project review's suggested order
(docs/PROJECT_REVIEW_PLAN.md):
- Restore TestJobListViewIsCompact, accidentally dropped by 5b0e6fe;
drop the redundant TestDefaultConfigUsesDetailedJobList row from
TESTS.md and document the two other doc gaps the review found.
- Fix quoteLeadingWindowsProgramPath to find the earliest file-extension
match at a word boundary instead of the first extension in list order,
so a .bat/.cmd command whose argument ends in .exe no longer has its
whole command line mistaken for the program path.
- Write gosentry.json, jobs.json, and run log files atomically (temp
file + rename) so a crash or power loss mid-write can no longer leave
a truncated file. Wire Service.Stop() into the app shutdown path so
it actually runs, cancelling the run context for in-flight runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Seed Failure notification test in defaultJobs so new installs can verify
Settings notifications via Run now without scheduler spam.
Co-authored-by: Cursor <cursoragent@cursor.com>
Autostart entries pass --start-in-tray only when the tray is enabled; Settings warns that the notification icon needs a restart (Fyne limitation).
Co-authored-by: Cursor <cursoragent@cursor.com>
Items 1-3 of the 2026-08-04 test-suite review: TestCleanupLogsKeepsFilesWithinAgeLimit,
TestRunDueEmptyOverlapInheritsGlobal, and TestSameWindowsPathHandlesSpaces had
byte-identical coverage to an existing test and no assertion the survivor lacked.
storage.defaultJobs, the one accidental 0% coverage gap the review found, is now
covered and TESTS.md corrected to match. seed_test.go's itoa is replaced with
strconv.FormatInt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Jobs directory row named a folder and assumed the file inside it was
called jobs.json. It is now a Jobs file row: Browse opens a file picker
filtered to .json, the field stays editable so a file that does not exist yet
can be typed, and the job list can live under any name.
Config.JobsDir/jobs_dir becomes Config.JobsFile/jobs_file, holding the whole
path; Paths.JobsDir is derived from it so saves still create the folder. An
older gosentry.json is migrated on load by joining its jobs_dir with
jobs.json — the exact file that version used — and the retired key is dropped
when the config is rewritten. The default clears before unmarshalling, or a
file that omits jobs_file and a file that sets it would be indistinguishable
and the migration would never run.
Saving used to write the current job list over whatever was at the new path,
which made switching to an existing jobs file impossible: its contents were
destroyed. An existing file now wins. Its jobs are loaded, normalized, and
adopted, with runtimes, schedule cache, next-run times and log-seeded
statistics rebuilt around them by adoptJobsLocked — the same helper NewService
now uses, so construction and adoption cannot drift. A path with no file
behind it still receives the current jobs, which is how the file is renamed or
relocated. The new file is read before anything is written, so an unparsable
one leaves both the config and the jobs untouched.
Adoption drops every runtime, and a run finishing afterwards would write its
result onto whichever job inherited its ID, so the switch is refused while a
job is running. Unrelated settings still save during a run. Because the
replacement happens without a prompt, the Service emits JobsLoaded with the
path and count, and History carries the receipt.
A path that names only a folder (trailing separator, a dot, or two dots) is
rejected with a validation error instead of failing later with an opaque OS
error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading a log file meant copying the configured path out of Settings and
pasting it into a file manager. The Logs directory row now carries an Open
button beside Browse that reveals the folder directly.
The new src/platform/filemanager package holds the platform split — explorer
on Windows, xdg-open on Linux, an "unsupported" error elsewhere — and starts
the handler without waiting on it, since Explorer exits non-zero even after it
opens the window and blocking would stall the UI thread. A missing path, a
path that is a file, and a handler that will not start are all reported to the
user; the logs directory does not exist until the first run, so that case is
reachable.
The button opens whatever the field currently holds rather than the saved
config, so an edit can be checked before Save. Resolving a relative directory
against the application folder is the store's rule, so resolveConfiguredDir is
now exported as storage.ResolveConfiguredDir instead of being duplicated in
the UI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each job can now render as a single line — name on the left, status on the
right — instead of the three-line block, so many more jobs fit without
scrolling. A toggle button beside the Folder filter switches between the two
modes and is labelled with the action it performs, matching the existing
"Disable auto" convention.
The choice is persisted as Config.JobListView ("detailed" / "compact", stored
as job_list_view in gosentry.json). Empty, legacy, and unrecognised values all
normalize to detailed, so existing installs keep the current look and the file
never gains a value no reader understands.
Selection, the details panel, the folder filter, and live status updates work
unchanged in both modes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-job timeout now has three distinct states: unset inherits the global
default, an explicit 0 means no timeout and does not inherit, and a positive
value is the per-job limit. Job.TimeoutSeconds became *int so unset and 0 stay
distinguishable in jobs.json.
Also fixes the global default, which could not persist a 0. loadOrCreateConfig
normalized DefaultTimeoutSeconds <= 0 back to 30 on every read of an existing
gosentry.json, so "no timeout" only held until the next restart. The field is
now written unconditionally (no omitempty) and read back as-is.
Existing jobs and configs are unaffected: a job with no timeout_seconds still
inherits, and a saved global default of 30 stays 30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DefaultTimeoutSeconds now means "no timeout" when 0/empty, and that is
the new default, rather than an invalid config forcing a positive
value. runner.RunJob avoids context.WithTimeout with a zero duration
(which would expire immediately) and instead runs on a plain
cancelable context when no timeout is configured. Per-job
TimeoutSeconds inherit semantics are unchanged.
Cancel discards unsaved edits by reloading the saved config; Defaults
loads built-in default values into the form for review before saving.
Extracts the default config into domain.DefaultConfig() so the store
and settings UI share one source of truth.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants, and let the user choose between it and Fyne's default theme
from Settings. The dark variant uses deep-teal surfaces to echo the app
icon.
The choice is persisted as a new Config.Theme field ("default" /
"gosentry"), applied at startup before the first frame and live-previewed
when picked in Settings. Empty/legacy configs normalize to the default
theme so existing installs keep the original look.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional per-job run timeout following the overlap_policy inherit
pattern: Job.TimeoutSeconds (0 = inherit) resolves against a new
Config.DefaultTimeoutSeconds (default 30s), replacing the hard-coded 30s
guard in runner.RunJob.
- domain/storage: new fields, default 30, load-time normalization
- runner: RunJob takes an explicit timeout; StartOnly stays untimed so it
keeps measuring launch latency only
- app: effectiveTimeout resolves under mu into runEnv, threaded to runJob;
seam signature and validation updated; DisplayTimeout helper
- ui: Timeout entry in the job dialog, Default timeout in Settings, and a
Timeout row in the details panel
- tests + docs (ARCHITECTURE, STANDARDS, ROADMAP, CHANGELOG) updated;
version bumped to 0.12.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
T6.1: split jobs_view.go into three files — jobs_view_helpers.go (pure
helpers) and jobs_view_details.go (detailsPanel struct with widget
creation, update, clear, and container methods) — bringing jobs_view.go
from 459 to ~200 lines.
T6.2: remove stale YAML upgrade note from README; drop *.yaml from
.dockerignore.
T6.3: delete YAML shadow structs (yamlConfig/yamlJob/yamlJobsFile),
importYAMLConfig/importYAMLJobs, legacy path constants, and all
YAML-import tests; run go mod tidy to remove go.yaml.in/yaml/v4.
T6.4: refresh ARCHITECTURE.md — JSON storage references, new Key Domain
Concepts section (per-job overlap policy, run-time statistics + log
seeding, persisted pause flag, jobs_view split).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Config.Paused bool so the scheduler's paused flag survives a restart.
SetGlobalPause now writes the flag into store.Config and calls SaveConfig;
NewService seeds s.paused from Config.Paused before computing first next-run
times, so jobs show "Scheduler paused" immediately at startup when paused.
Also add Paused to the yamlConfig shadow struct to keep the direct conversion
from domain.Config valid until the YAML import path is dropped in T6.3.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds OverlapPolicy field to Job struct with json:"overlap_policy,omitempty"
tag. When empty, the field signals that the job should inherit the global
overlap policy setting from Config. Also updates yamlJob shadow struct to
maintain field-layout equivalence for YAML→JSON import compatibility.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Introduces ExecutionMode (parallel/sequential) and OverlapPolicy
(skip/queue) types and constants in domain/config.go, wires defaults
(parallel/skip) into loadOrCreateConfig and the normalization pass, and
adds validation in validateConfig. Adds Pending bool to JobRuntime as
the flag P3.3 will use to re-run a queued overlap. Marks P3.1 done.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the SuccessExitCodes field from domain.Job and every layer that
read or wrote it: runner/exitcodes.go (deleted), runner.go runStateDetail
simplified to 0=OK / non-zero=Failed, logfile.go, format.go, operations.go,
store.go, job_dialog.go, and jobs_view.go. Tests updated accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When gosentry.json / jobs.json are absent, read a pre-migration
gosentry.yaml / jobs.yaml via private yaml-tagged shadow structs and
return the data unsaved; the existing SaveConfig/SaveJobs in OpenStore
then rewrite it as JSON. Replaces the placeholder that pointed configPath
at the legacy YAML file and tried to json.Unmarshal it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ConfigFileName → "gosentry.json", JobsFileName → "jobs.json"
- Remove exported LegacyConfigFileName ("pysentry.yaml")
- Add unexported legacyYAMLConfigFileName / legacyYAMLJobsFileName for
the upcoming one-time YAML import (P1.4)
- Update store.go fallback path and comments to describe YAML→JSON
migration rather than the old PySentry→GoSentry rename
- Align store_test.go references and test comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace writeYAML with writeJSON (json.MarshalIndent, 2-space indent,
trailing newline) and switch the config and jobs Unmarshal calls to
encoding/json. Mark P1.2 done in the pre-release task list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
Completed Phase 5 refactoring and reached the target architecture.
**Architectural milestone achieved:**
- Service layer owns all state and is the sole writer
- UI is a thin Fyne view, all widget updates marshaled via `fyne.Do`
- Core engines are stateless and injectable
- Domain types are pure (no `yaml:"-"` fields)
- Full module builds and `go vet ./...` clean
## Changes
- Bump version: 0.3.6 → 0.4.0
- Update CHANGELOG with Phase 5 summary
- Add ROADMAP "Refactoring Follow-Ups" section
## Known follow-up work
1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag
2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines)
See ROADMAP.md for details.
---------
Co-authored-by: mixeme <mix.public@ya.ru>
Reviewed-on: #1