* 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>
18 KiB
Auto-Update PR 4 — Tier 4 (autonomous in maintenance window) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Land Tier 4 of the auto-update subsystem: when a new release is detected and updates.tier == "autonomous" on a writable install with a valid updates.maintenanceWindow, schedule the update so that the drain only starts while now() is inside the window. Outside the window, the schedule is deferred to the next opening. The admin UI gains a window picker (start/end HH:MM, tz local|utc) with validation and a "next window opens at..." preview.
Architecture: Add a new pure module MaintenanceWindow.ts with inWindow(now, window) and nextWindowStart(now, window). Both handle cross-midnight (end < start), local- vs utc-tz selection, and DST transitions (compute against the configured wall clock, not UTC offsets that shift). The Scheduler.decideSchedule() and decideTriggerApply() decisions take a new maintenanceWindow input and a canAutonomous policy bit; when the tier is autonomous, schedules are placed at max(now + grace, nextWindowStart) and trigger-apply aborts (back to scheduled) if the window has closed by fire time. UpdatePolicy.canAutonomous flips on for git + tier:autonomous + valid window. Admin UI adds a picker bound to updates.maintenanceWindow via the existing settings round-trip; the UpdatePage scheduled panel shows the resolved next-window time.
Tech Stack: TypeScript (Node ≥ 25), Express, log4js, vitest (unit), mocha + supertest (HTTP integration), Playwright (admin UI), React + Zustand (admin UI).
File structure
New files
src/node/updater/MaintenanceWindow.ts— pureinWindow(now, window)+nextWindowStart(now, window). No I/O.src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts— vitest unit. Same-day, cross-midnight, exact boundary, tz=utc vs tz=local, DST spring-forward + fall-back.src/tests/backend/specs/updater-window-integration.ts— mocha integration. Latest release detected outside window queues for next opening; entering window triggers fire-now (or grace+window); cancel during deferred-grace returns to idle; window closes mid-grace defers to next window without dropping the schedule.admin/src/components/MaintenanceWindowPicker.tsx— small controlled component: start (HH:MM), end (HH:MM), tz select, validation message, "next window opens at..." preview.src/tests/frontend-new/admin-spec/update-autonomous.spec.ts— Playwright: window picker round-trips through Settings; scheduled panel renders "next window opens at..." when waiting; cancel works.
Modified files
src/node/updater/types.ts— addMaintenanceWindowtype ({start: string; end: string; tz: 'local' | 'utc'}), threadmaintenanceWindow: MaintenanceWindow | nullthroughPolicyInput.src/node/updater/UpdatePolicy.ts—canAutonomousflips on forgit + tier === 'autonomous'AND a non-null, schema-validmaintenanceWindow. Add new policyreasonvalue'maintenance-window-missing'(denied tier 4 when window not configured) and'maintenance-window-invalid'(denied tier 4 when window fails parse).src/node/updater/Scheduler.ts— extendDecideScheduleInputwithmaintenanceWindow+canAutonomous; when canAutonomous,scheduledFor = max(now+grace, nextWindowStart(now+grace, window)). ExtenddecideTriggerApply()so that when canAutonomous andinWindow(now, window) === false, return new action{action: 'defer'; nextStart: string}. ExtendSchedulerRunnerto re-arm on defer.src/node/updater/index.ts— passupdates.maintenanceWindow+ the autonomous bit intodecideSchedule/decideTriggerApply. Ondefer, persist newscheduledForand re-arm. Log line atinfo:updater: deferred to next maintenance window at <iso>.src/node/utils/Settings.ts— addmaintenanceWindow: MaintenanceWindow | nullto theupdatessettings type; defaultnull. Validate shape on boot; on invalid, log a warning and treat as null (do not crash boot).settings.json.template+settings.json.docker— add"maintenanceWindow": nullline with comment showing example{"start":"03:00","end":"05:00","tz":"local"}.src/node/hooks/express/updateStatus.ts— surfacenextWindowStart(computed at request time when tier is autonomous + window set) inGET /admin/update/statusresponse so the admin UI can show "next window opens at...".src/locales/en.json—update.window.start,update.window.end,update.window.tz_local,update.window.tz_utc,update.window.validation.format,update.window.validation.equal,update.window.next_opens_at,update.page.scheduled.deferred_until,update.page.policy.autonomous_no_window,update.page.policy.autonomous_invalid_window.admin/src/store/store.ts— extendSettings.updateswithmaintenanceWindow; extend response shape returned by/admin/update/statuswith optionalnextWindowOpensAt: string | null.admin/src/pages/UpdatePage.tsx— renderMaintenanceWindowPickerwhentier === 'autonomous'. Render "Deferred — next window opens at ..." whenexecution.status === 'scheduled'andscheduledFor > now. Show explicitpolicy.reasontext forautonomous_no_windowandautonomous_invalid_window.admin/src/components/UpdateBanner.tsx— add a banner variant whentier === 'autonomous'but window is missing/invalid: "Autonomous updates are disabled until a maintenance window is configured." Links to/admin/update.doc/admin/updates.md— flip Tier 4 from "designed, not yet implemented" to current; documentmaintenanceWindowshape, cross-midnight, DST behavior, fallback when window is missing.CHANGELOG.md— Unreleased section entry under### Added.docs/superpowers/specs/2026-04-25-auto-update-runbook.md— append Tier 4 smoke section: configure window 5 min from now, observe deferral, walk window forward, observe fire.
Task 1: Settings schema for maintenanceWindow
Files:
- Modify:
src/node/utils/Settings.ts - Modify:
settings.json.template - Modify:
settings.json.docker - Modify:
src/node/updater/types.ts(exportMaintenanceWindow) - Test: extend an existing Settings-load test if one exists for
updates; otherwise rely on Task 4 unit coverage of the window module + boot-time log.
Steps:
- In
src/node/updater/types.tsaddexport interface MaintenanceWindow { start: string; end: string; tz: 'local' | 'utc' }. - In
src/node/utils/Settings.tsextend theupdatestype withmaintenanceWindow: MaintenanceWindow | null. Default tonullin the literal. - Add boot-time validation: regex
/^([01]\d|2[0-3]):[0-5]\d$/for bothstartandend; tz must be'local' | 'utc';start !== end. On invalid, log warning vialog4jscategoryupdaterand set tonull(do not crash). Validation lives in a small pure helper exported fromMaintenanceWindow.ts(parseWindow) so the policy and the UI can reuse it. - Edit
settings.json.templateandsettings.json.dockerto include"maintenanceWindow": nullimmediately belowtier, with a comment showing the shape.
Verification:
pnpm exec tsc --noEmitclean.- Boot the server with a deliberately malformed window (
{"start":"oops"}) and confirm the warning is logged and tier downgrades toautoeffectively (canAutonomous=false via the policy reason'maintenance-window-invalid').
Task 2: MaintenanceWindow.ts module + unit tests
Files:
- Create:
src/node/updater/MaintenanceWindow.ts - Create:
src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts
Steps:
- Export
parseWindow(raw: unknown): MaintenanceWindow | null(returnsnullif shape/format invalid). - Export
inWindow(now: Date, window: MaintenanceWindow): boolean. Compare against the configured tz's wall clock. Fortz: 'utc'usegetUTCHours/Minutes; fortz: 'local'usegetHours/Minutes. Cross-midnight (end < start): inside ifnow ≥ start || now < end. - Export
nextWindowStart(now: Date, window: MaintenanceWindow): Date. Returns the nextDatewhose wall-clock time equalsstartin the configured tz and which is ≥now. Fortz: 'local'this is straightforward; fortz: 'utc'build viaDate.UTC. Document via inline comment that DST spring-forward will be handled by the host'ssetTimer/setTimeoutand we never schedule "into the gap" because we always compare against wall clock.
Tests (vitest):
inWindow— same-day window 03:00-05:00 (inside at 03:30, outside at 02:59, outside at 05:00 (exclusive end)).inWindow— cross-midnight 22:00-02:00 (inside at 23:00 and at 01:00; outside at 02:00 and 21:59).inWindow— tz=utc respects UTC clock regardless of host TZ (run withTZ=America/Los_Angeles).nextWindowStart— whennowis before today's start, returns today at start.nextWindowStart— whennowis inside the window, returns next day's start (callers gate fire-now viainWindow, notnextWindowStart).nextWindowStart— DST spring forward (America/New_York, 2026-03-08, window 02:30-03:30 local):nextWindowStartfornow = 2026-03-08T06:00:00Zresolves to the next wall-clock 02:30 (which is actually 03:30 local on the DST day; document this in the test).nextWindowStart— DST fall back (America/New_York, 2026-11-01, window 01:30-02:30 local): assertion thatnextWindowStartreturns the first 01:30 wall-clock occurrence.parseWindow— accepts{start:"03:00",end:"05:00",tz:"local"}; rejects missing fields, malformed times,start===end, unknown tz.
Verification:
pnpm exec vitest run src/tests/backend-new/specs/updater/MaintenanceWindow.test.tsgreen.
Task 3: Extend UpdatePolicy with canAutonomous and window args
Files:
- Modify:
src/node/updater/UpdatePolicy.ts - Modify:
src/node/updater/types.ts(extendPolicyInput) - Modify:
src/tests/backend-new/specs/updater/UpdatePolicy.test.ts
Steps:
- Extend
PolicyInputwithmaintenanceWindow: MaintenanceWindow | null(optional, defaults to null in callers). - Modify
evaluatePolicy: whentier === 'autonomous'and writable and not terminal:- if
maintenanceWindow == null,canAutonomous = false,reason = 'maintenance-window-missing', but keepcanAuto = true,canManual = true(degrade to Tier 3 behavior). - if
parseWindow(maintenanceWindow) == null, same as above withreason = 'maintenance-window-invalid'. - otherwise
canAutonomous = true.
- if
- Update existing tests that asserted
canAutonomous: truefortier: 'autonomous'without a window — they now expectcanAutonomous: false, reason: 'maintenance-window-missing'. Add new cases for the three policy outcomes.
Verification:
pnpm exec vitest run src/tests/backend-new/specs/updater/UpdatePolicy.test.tsgreen.
Task 4: Scheduler — gate scheduling + firing on window
Files:
- Modify:
src/node/updater/Scheduler.ts - Modify:
src/tests/backend-new/specs/updater/Scheduler.test.ts(extend; create if absent)
Steps:
- Extend
DecideScheduleInputwithmaintenanceWindow: MaintenanceWindow | nulland usepolicy.canAutonomousto decide whether to apply the window gate. - In
decideSchedule, after the existing grace computation, ifcanAutonomous && maintenanceWindow:- candidate
scheduledFor = now + grace. - if
inWindow(candidate, window) === false, setscheduledFor = nextWindowStart(candidate, window). - keep the rest of the email/dedupe machinery untouched (
grace-startemail cadence still fires once per tag).
- candidate
- In
decideTriggerApply, add a parameter for the resolved policy plus the window/now. Ifpolicy.canAutonomous && !inWindow(now, window), return new decision{action: 'defer'; nextStart: string}. The runner persistsscheduledFor = nextStartand re-arms. - In
SchedulerRunner, extend the timer-fire callback to calltriggerApplyand, ondefer, re-arm without firing. (The runner is already idempotent onarm.)
Tests (vitest):
decideSchedule— canAutonomous + window 03:00-05:00 + now=10:00 →scheduledForsnapped to the next 03:00 (notnow + grace).decideSchedule— canAutonomous + window 03:00-05:00 + now=03:30 with grace=0 →scheduledForisnow(inside window, no snap).decideTriggerApply— canAutonomous + outside window →{action: 'defer', nextStart: <iso>}.decideTriggerApply— canAutonomous + inside window →{action: 'fire'}.- Email dedupe: defer does not trigger a new
grace-startemail.
Verification:
pnpm exec vitest run src/tests/backend-new/specs/updater/Scheduler.test.tsgreen.
Task 5: Wire scheduler runner + status endpoint to surface window state
Files:
- Modify:
src/node/updater/index.ts - Modify:
src/node/hooks/express/updateStatus.ts - Modify:
src/tests/backend/specs/updater-actions.ts(or the equivalent status test) — extend to assertnextWindowOpensAtis present when tier=autonomous + window set.
Steps:
- In the periodic check loop, pass
settings.updates.maintenanceWindowintodecideSchedule. Pass policy result into bothdecideScheduleanddecideTriggerApply. - On
{action: 'defer'}, writestate.execution.scheduledFor = nextStart, persist,runner.arm(...). Emit a log line at INFO categoryupdater. - In
updateStatus.ts, whentier === 'autonomous'andmaintenanceWindowparses, computenextWindowOpensAt = nextWindowStart(now, window)and include in the JSON response (nullotherwise).
Verification:
pnpm exec mocha src/tests/backend/specs/updater-actions.tsgreen.
Task 6: Admin UI — MaintenanceWindowPicker + scheduled-panel "deferred until"
Files:
- Create:
admin/src/components/MaintenanceWindowPicker.tsx - Modify:
admin/src/pages/UpdatePage.tsx - Modify:
admin/src/components/UpdateBanner.tsx - Modify:
admin/src/store/store.ts - Modify:
src/locales/en.json - Test:
src/tests/frontend-new/admin-spec/update-autonomous.spec.ts
Steps:
MaintenanceWindowPicker.tsx— controlled component overvalue: {start, end, tz} | null, emitsonChange. Inline validation message via i18n keysupdate.window.validation.format/update.window.validation.equal. Below the picker, render the resolvednextWindowOpensAt(passed in via prop) with keyupdate.window.next_opens_at.- In
UpdatePage.tsx, whensettings.updates.tier === 'autonomous', render the picker. Wiring through the existing settings round-trip (the parsed settings editor PR #7709 lands first; if it's not yet on develop at integration time, fall back to writing through/admin/settings). - When
execution.status === 'scheduled'andpolicy.canAutonomousandscheduledFor > now, render the scheduled panel with the deferral subtitle (update.page.scheduled.deferred_until). - In
UpdateBanner.tsx, render the "configure maintenance window" banner whenpolicy.reason === 'maintenance-window-missing' | 'maintenance-window-invalid'andtier === 'autonomous'. - Add all i18n keys to
en.json. Always i18n, never hardcoded (memory:feedback_always_i18n).
Tests (Playwright):
- Window picker saves a value; reload restores it.
- Invalid input shows the validation message and does not save.
- When tier=autonomous + window set + outside window, the scheduled panel shows "Next window opens at HH:MM (local)".
- When tier=autonomous + window missing, the banner renders the link to
/admin/update.
Verification:
pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.tsgreen (port 9003 per memoryfeedback_test_port_9003).
Task 7: Window-boundary integration test
Files:
- Create:
src/tests/backend/specs/updater-window-integration.ts
Cases:
- Outside window: VersionChecker sees a new release; Scheduler arms
scheduledFor = nextWindowStart; no drain starts. - Enter window: clock advances to inside-window; fire-time
decideTriggerApplyreturnsfire; drain starts. - Cancel during deferred-grace:
/admin/update/cancelreturns 200 andexecution.statusreturns toidle. - Window closes mid-grace: clock advances past
endbefore fire;decideTriggerApplyreturnsdefer; state persists with newscheduledFor; runner re-arms.
Verification:
pnpm exec mocha src/tests/backend/specs/updater-window-integration.tsgreen.
Task 8: Docs, runbook, CHANGELOG
Files:
- Modify:
doc/admin/updates.md - Modify:
docs/superpowers/specs/2026-04-25-auto-update-runbook.md - Modify:
CHANGELOG.md
Steps:
- Flip the Tier 4 section in
doc/admin/updates.mdfrom "designed, not yet implemented" to current. DocumentmaintenanceWindowshape, cross-midnight, DST behavior, and the policy fallback when the window is missing or invalid. - Append a Tier 4 smoke section to the runbook: configure window 5 min from now, observe deferral, walk window forward, observe fire, observe rollback path inside window still works.
- Add an
Unreleasedentry toCHANGELOG.mdunder### Added.
Verification:
- Manual:
pnpm run devon a clean checkout withtier: "autonomous"+ a near-future 2-minute window and confirm the admin UI matches the documented flow.
Cross-cutting checks before opening the PR
pnpm exec tsc --noEmitclean (root + admin).pnpm exec vitest rungreen (backend-new).pnpm exec mocha src/tests/backend/specs/updater-*.tsgreen.- Playwright admin spec green under
pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.tson port 9003. pnpm run build:uisucceeds.- Manual smoke runbook Tier 4 section completed against a disposable VM (canary deferred to merge if the 2-week canary requirement from spec §"Ship gate" is dropped; otherwise gate merge on canary).
- PR title
feat(updater): tier 4 — autonomous update in maintenance window (#7607). - PR body links to the spec + this plan, lists settings additions, and links to PRs #7601 / #7704 / #7720.
- After merge, close issue #7607 with a summary comment linking all four PRs.