feat: select the jobs file itself in Settings

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>
This commit is contained in:
mixeme
2026-07-26 23:43:56 +03:00
parent 5a018d03cb
commit 721b049100
17 changed files with 585 additions and 85 deletions
+9 -1
View File
@@ -51,7 +51,7 @@ flowchart LR
runner -->|"execute command"| shell
runner -->|"write stdout/stderr log"| logs
runner -->|"RunRecord"| svc
svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui
svc -->|"emit JobChanged / RunRecorded / JobsLoaded / ErrorOccurred"| ui
ui -->|"display jobs, history, status"| user
ui -->|"SetAutostart, AutostartStatus"| autostart
@@ -75,6 +75,14 @@ flowchart LR
`Event`. The UI's observer receives the event and refreshes the relevant
widget on the main thread via `fyne.Do`.
`UpdateSettings` has one extra step: when the configured jobs file changes
and a file already exists at the new path, that file is authoritative. The
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
map, schedule cache, next-run times, and log-seeded statistics around it, and
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
receives the current jobs instead. Adoption drops all runtime state, so it is
refused while a job is running.
3. Scheduled run:
`scheduler.Scheduler` fires a tick every second. On each tick it calls
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
+42
View File
@@ -2,6 +2,48 @@
All notable GoSentry changes are recorded in this file.
## 0.15.0 - 2026-07-26
**Settings points at the jobs file itself, not the folder holding it.**
**Settings:**
- The **Jobs directory** row is now a **Jobs file** row. Browse opens a file
picker filtered to `.json` instead of a folder picker, so the job list can
live under any file name — `team-jobs.json`, one file per machine, a file
shared over a network drive — rather than a fixed `jobs.json` per folder. The
field stays editable, which is how a file that does not exist yet is named.
- **Selecting an existing jobs file now loads it.** Previously the current job
list was written over whatever was at the new path, which made it impossible
to switch to an existing jobs file — its contents were destroyed on Save. Now
an existing file wins: its jobs are loaded, normalized, and replace the loaded
list, with runtimes, parsed schedules, next-run times, and log-seeded
statistics rebuilt around them. A path with no file behind it still receives
the current jobs (and its folder is created), which is how the jobs file is
renamed or relocated. History records `Jobs loaded — N jobs from <path>`,
since the switch happens without a prompt.
- Switching to a different jobs file is refused while a job is running: adoption
discards every runtime, and a run finishing afterwards would write its result
onto whichever job inherited its ID. Settings unrelated to the jobs file still
save normally during a run.
- Saving a path with no file name (a trailing separator, `.`, `..`) is rejected
with "jobs file must include a file name" instead of failing later with an
opaque OS error.
**Configuration:**
- `Config.JobsDir` / `jobs_dir` is replaced by `Config.JobsFile` / `jobs_file`,
which holds the full path including the file name; the default is
`"jobs.json"`, resolved against the program folder as before. `Paths.JobsDir`
is now derived from the configured file so job saves still create the folder.
- A `gosentry.json` written by an earlier version is migrated on load: its
`jobs_dir` is joined with `jobs.json`, which is the exact file that version
used, and the retired key is dropped when the config is rewritten.
- New `app.JobsLoaded{Path, Count}` event, emitted when a selected jobs file
replaces the job list; the UI turns it into the History entry. New
`storage.LoadJobsFile`, which reads and normalizes a jobs file and reports a
missing one as "not found" instead of seeding it the way startup does.
## 0.14.0 - 2026-07-26
**Compact job list view, "no timeout" at both timeout levels, and an Open
+12
View File
@@ -29,12 +29,24 @@ change to their shape has to stay compatible on its own.
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.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 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`
+8
View File
@@ -130,6 +130,11 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|------|---------|
| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. |
| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. |
| `TestHasFileName` | Verifies the jobs-file path check: a file name passes; a trailing separator, `.`, and `..` do not. |
| `TestUpdateSettingsWritesJobsToTheNewFile` | Verifies that changing `JobsFile` re-resolves `Paths.JobsPath` and writes the loaded jobs to the new file, creating its folder. |
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
---
@@ -204,6 +209,9 @@ Tests JSON round-tripping and default generation.
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
---