Commit 962bfe8
* docs(updater): plan tier 4 — autonomous update in maintenance window (#7607)
Maps PR 4 of the auto-update design spec (§"Tier 4 — autonomous") to concrete
files, tasks, and verification steps. Subsequent commits scaffold against this
plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): MaintenanceWindow module — wall-clock window math for tier 4
Pure module: parseWindow, inWindow, nextWindowStart. Supports tz=local|utc
and cross-midnight ranges. Used by upcoming Scheduler + UpdatePolicy changes.
22 vitest unit tests cover format validation, same-day + cross-midnight
boundaries, and host-local vs UTC clock comparisons. DST handling is
absorbed by JS Date constructor's wall-clock normalization (documented in
the file header).
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): tier 4 backend — window-gated UpdatePolicy + Scheduler
Wires MaintenanceWindow into the existing tier 3 backend so autonomous
updates only fire while `now` is inside `updates.maintenanceWindow`.
UpdatePolicy
- new optional `maintenanceWindow` input
- canAutonomous flips on only for git+tier=autonomous+parse-valid window
- new reasons `maintenance-window-missing` / `maintenance-window-invalid`
- rollback-failed still wins over window denial
Scheduler
- decideSchedule snaps scheduledFor forward to nextWindowStart when
canAutonomous + grace lands outside the window
- decideTriggerApply returns a new `{action: 'defer'}` when canAutonomous
+ fire-time is outside the window; carries nextStart for the runner
- canAutonomous=false preserves Tier 3 behavior unchanged
index.ts wires settings.updates.maintenanceWindow through both passes and
re-arms the timer on defer. Status endpoint surface (nextWindowOpensAt) +
admin UI picker land in a follow-up commit.
Settings adds `maintenanceWindow: {start, end, tz} | null`, defaulting to
null. settings.json.template / settings.json.docker document the shape.
Tests
- 22 vitest cases for MaintenanceWindow already cover the math
- 4 new UpdatePolicy cases for the window outcomes
- 6 new Scheduler cases for tier-4 schedule/trigger paths
- Full backend-new suite: 629 passed (35 files)
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): tier 4 admin UI — window status, deferred subtitle, banner
GET /admin/update/status now returns:
- `maintenanceWindow`: the parsed window object (admin sessions only)
- `nextWindowOpensAt`: ISO of the next window opening when tier=autonomous
UpdatePage
- new "Maintenance window" section when tier=autonomous, shows current
window summary + next opens at, or "Not configured" when unset
- scheduled panel now appends a "deferred until <iso>" line when the
backend has snapped scheduledFor to the next window opening
UpdateBanner
- new variant when tier=autonomous and policy.reason is
`maintenance-window-missing` or `maintenance-window-invalid`, linking
to /admin/update
i18n
- 8 new keys under `update.banner.*`, `update.page.policy.*`,
`update.page.scheduled.*`, `update.window.*` (en.json only;
translations follow via the usual locale workflow)
Interactive picker is intentionally deferred — admins edit
`updates.maintenanceWindow` via the parsed JSONC settings editor (#7709).
A follow-up commit may add a thin write-through component if the JSONC
round-trip turns out to be too rough for typical operators.
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(updater): tier 4 — window gate, DST notes, runbook §12 (#7607)
CHANGELOG: flip Tier 4 from "designed, not yet implemented" to current.
Document maintenanceWindow shape, snap-forward, defer-at-fire, and the
two missing/invalid policy reasons.
doc/admin/updates.md: new "Tier 4 — autonomous in a maintenance window"
section with config example, policy gating, DST/timezone notes, admin UI
behavior.
runbook: §12 walks a disposable VM through missing-window, malformed,
outside-window deferral, fire-at-opening, and window-closes-mid-grace.
Adds five sign-off checklist items.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(updater): tier 4 window-boundary integration (#7607)
Mocha integration covering the four scenarios called out in the spec
§"Tier 4 — autonomous":
- outside-window: decideSchedule snaps scheduledFor forward to the
next opening and the snapped value round-trips through saveState
- inside-window at fire-time: decideTriggerApply returns fire
- window-closes-mid-grace: decideTriggerApply returns defer with
nextStart at the next opening; persisted state moves forward
- cancel during deferred-grace: state returns to idle, and the next
decideSchedule pass re-emits a schedule snapped to the next opening
All 4 cases passing locally under tsx mocha.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): real SMTP via nodemailer (mail.* settings) (#7607)
Replaces the (would send email) stub introduced in PR #7601 with a
nodemailer-backed transport. The dependency is lazy-imported so installs
that don't set mail.host pay no runtime cost.
Settings additions
- new top-level mail block: host, port, secure, from, auth (user/pass)
- mail.host=null keeps the legacy log-only behaviour; the Notifier
still updates dedupe state so we don't re-evaluate every tick
- settings.json.template documents the shape inline
- settings.json.docker reads MAIL_HOST / MAIL_FROM / MAIL_PORT /
MAIL_SECURE from env so operators can configure via container env
Transport
- lazy import('nodemailer') on first send
- transport cached by host; settings reload picks up new host without
needing a restart
- send errors are swallowed (logged warn) so a transient SMTP failure
can never poison the surrounding updater state machine
- successful sends log at info; legacy "(would send email)" path
remains the visible signal when mail is disabled
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): preflight checks target tag's engines.node (#7607)
Before mutating the working tree, runPreflight now reads the target tag's
package.json via `git show <tag>:package.json` and verifies that
process.versions.node satisfies its engines.node range. Failures land at
preflight-failed cleanly (no rollback needed — nothing has changed yet).
Motivation: a release that bumps the Node floor used to either fail
mid-`pnpm install` (which then rolls back successfully) or restart on the
new build and crash in the boot path (which then rolls back via the
health-check timer). Both paths recover, but they burn a drain + restart
cycle on a condition we can reject upfront.
Implementation
- new PreflightReason `node-engine-mismatch`
- new dep `readTargetEnginesNode(tag)` — runs the git-show as a child
process with stdio captured to a string; missing tag / missing file /
malformed JSON / missing engines.node all resolve to null (treated as
"no constraint, pass")
- uses existing semver dep with includePrerelease: true
- new PreflightInput field `currentNodeVersion`; threaded from
process.versions.node in both wirings (scheduler + manual apply)
- check runs *after* signature verification so we trust the package.json
- PreflightResult carries an optional `detail` string; applyPipeline
appends it to the lastResult.reason so the admin UI shows e.g.
"node-engine-mismatch: target requires Node >=26.0.0, running 25.0.0"
Tests: 6 new vitest cases (no engines.node, satisfies, fails below floor,
caret range, loose-spaced range, ordering after signature). Full
backend-new: 635 passed (was 629).
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(updater): email admin on auto-rollback / preflight-failed (#7607)
Before this commit, only the terminal rollback-failed state emailed the
admin. Auto-recovered failures (rolled-back-install-failed, rolled-back-
build-failed, rolled-back-health-check, rolled-back-crash-loop) and pre-
flight-failed surfaced only via the /admin/update banner — so a 3am
autonomous update that failed because of, say, a Node engine bump would
roll back silently and stay invisible until the admin next logged in.
Notifier
- new EmailKinds: 'update-preflight-failed', 'update-rolled-back',
'update-rollback-failed'
- new pure decideOutcomeEmail(input) → {toSend, newState}
- dedupe key `<outcome>:<targetTag>` in EmailSendLog.lastFailureKey:
same outcome on same tag emits one email per cycle (kills retry-loop
spam); a different outcome or different tag resets the key
- rollback-failed always fires (terminal — overrides dedupe)
- state.ts validator + loadState backfill the new field for legacy
state files (Tier 1/2/3 installs upgrading in place)
Wiring
- new index.ts helper notifyApplyFailure() loads state, runs the pure
notifier, sends (via the nodemailer-backed sendEmailViaSmtp from the
previous commit), persists the new dedupe key — all best-effort
- schedulerTriggerApply: fires on applyUpdate returning preflight-failed
or rolled-back
- /admin/update/apply HTTP handler: same
- boot path in expressCreateServer: if state.lastResult is a failure
outcome we haven't already emailed about, fire then. Covers:
- health-check timeout rollback (timer expired between boots)
- crash-loop forced rollback caught on a later boot
- preflight-failed where the process didn't get to email before exit
- unacknowledged rollback-failed terminal
Tests
- 8 new vitest cases for decideOutcomeEmail (adminEmail=null, each
outcome's content, dedupe by tag, dedupe by outcome, rollback-failed
bypass)
- Full backend-new suite: 643 passed (was 635)
Refs #7607
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): address Qodo review on tier 4
- UpdatePage: only show "deferred until" subtitle when scheduledFor
actually matches nextWindowOpensAt. The previous `scheduledFor >
now + 60s` heuristic misfired during a normal in-window 15-min
grace period.
- applyPipeline: return the enriched preflight reason (`reason:
detail`) instead of only `pf.reason`, so /admin/update/apply 409
bodies and failure-notify emails preserve diagnostics like the
Node engine mismatch detail.
- updater/index: key the cached nodemailer transport on the full
set of SMTP options (host + port + secure + auth) so runtime
changes to port/credentials via reloadSettings() invalidate
the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dbd4662 commit 962bfe8
32 files changed
Lines changed: 1678 additions & 50 deletions
File tree
- admin/src
- components
- pages
- store
- docs/superpowers
- plans
- specs
- doc/admin
- src
- locales
- node
- hooks/express
- updater
- utils
- tests
- backend-new/specs/updater
- backend/specs
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
33 | 35 | | |
34 | 36 | | |
35 | 37 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
55 | 72 | | |
56 | 73 | | |
57 | 74 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
195 | 243 | | |
196 | 244 | | |
197 | 245 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
28 | 34 | | |
29 | 35 | | |
30 | 36 | | |
| |||
44 | 50 | | |
45 | 51 | | |
46 | 52 | | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
47 | 56 | | |
48 | 57 | | |
49 | 58 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| |||
192 | 192 | | |
193 | 193 | | |
194 | 194 | | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
0 commit comments