* feat(updater): scheduled execution state + graceStartTag dedupe field (#7607) Preparation for Tier 3 of the auto-update subsystem: - ExecutionStatus gains `scheduled` (targetTag, scheduledFor, startedAt). - EmailSendLog gains `graceStartTag` for one-shot grace-start email dedupe. - state validator accepts the new shape, requires per-status fields, and backfills graceStartTag=null on a Tier 1/2 state file. Plus the implementation plan at docs/superpowers/plans/2026-05-11-auto-update-pr3-tier3-auto.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): decideSchedule pure decision function (#7607) Adds src/node/updater/Scheduler.ts with the Tier 3 pure decision logic: - schedules when canAuto + idle/verified/terminal-cleared - reschedules when a newer tag appears mid-grace - emits a grace-start email (once per tag) when adminEmail is set - cancels a stale schedule when policy flips canAuto off - no-ops during in-flight / terminal states - clamps preApplyGraceMinutes to [0, 7 days] Also extends Notifier's EmailKind union with 'grace-start' so the decision result types correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): scheduler timer runner with arm/cancel (#7607) Adds createSchedulerRunner to Scheduler.ts: - arm(): clears any prior timer, sets a fresh one for scheduledFor - cancel(): clears the pending timer, idempotent - past scheduledFor → fires with delay=0 (rehydrate after restart-in-grace) - single-fire-per-arm semantics; armedFor cleared on fire Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(updater): extract apply pipeline shared by HTTP + scheduler (#7607) Lifts the preflight → drain → execute orchestration out of the /admin/update/apply HTTP handler into src/node/updater/applyPipeline.ts. The HTTP handler keeps its 4xx status mapping; the pipeline owns the state transitions, lock release, drain coordination, and rollback hand- off. The new ApplyPipelineDeps interface accepts an onAccepted callback so the HTTP path can still 202 mid-flow while the Tier 3 scheduler path (next commit) can no-op. Adds `scheduled` to the apply allowed-entry list so an admin can "Apply now" during the Tier 3 grace window. 13 vitest cases cover happy / preflight-failed / cancelled / busy / lock-held / scheduled-entry / rollback / lock-release. Existing 12 mocha integration tests still pass without change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): wire Tier 3 scheduler into boot + performCheck (#7607) - expressCreateServer instantiates the scheduler runner and rehydrates the timer when a prior boot left state.execution = scheduled - performCheck evaluates decideSchedule after the notifier pass: schedule transitions state + sends grace-start email + arms timer; cancel-schedule resets to idle + cancels timer - shutdown cancels the timer - exposes cancelScheduler() so the cancel endpoint (next commit) can drop the pending schedule - buildSchedulerApplyDeps() supplies the full production-wired pipeline deps (preflight, executor, rollback) for the scheduler-triggered apply Adds tests/backend/specs/updater-scheduler-integration.ts covering boot-rehydrate fire-on-past and the decision-to-state round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(updater): cancel handler supports Tier 3 scheduled state (#7607) POST /admin/update/cancel now accepts execution.status === 'scheduled' in addition to preflight/draining. The handler calls cancelScheduler() to drop the pending in-process timer, then transitions state to idle with lastResult.outcome = 'cancelled' (mirroring the existing pattern). Adds a Tier 3 integration test that seeds a scheduled state, calls /admin/update/cancel, and asserts the state machine landed correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(admin): countdown + cancel UI for Tier 3 scheduled updates (#7607) - store.ts: extend Execution union with the scheduled variant - UpdatePage.tsx: render countdown panel during scheduled; Apply button is relabelled "Apply now" so the admin can skip the remaining grace; Cancel button accepts scheduled state - UpdateBanner.tsx: dedicated scheduled banner with live remaining time - en.json: new i18n keys (execution.scheduled, banner.scheduled, page.scheduled.{title,countdown,apply_now}) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(updater): playwright spec for Tier 3 scheduled UI (#7607) Three cases against a mocked /admin/update/status: - countdown panel + Apply now + Cancel render when execution is scheduled - Cancel button posts /admin/update/cancel and triggers re-fetch - /admin (banner) shows "Auto-update to <tag> scheduled" copy Mirrors the existing update-page-actions.spec.ts mock pattern (page.route). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(updater): document Tier 3 auto with grace window (#7607) - doc/admin/updates.md: flip Tier 3 from "designed, not yet implemented" to current; expand preApplyGraceMinutes table row; add a Tier 3 section explaining schedule / cancel / Apply now / restart-in-grace and the grace-start email - settings.json.template: clarify the preApplyGraceMinutes comment - CHANGELOG.md: Unreleased entry for Tier 3 - runbook §11: full Tier 3 smoke (happy, cancel, apply-now, restart-in- grace, email) plus the additional sign-off checkboxes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin): UpdatePage handles missing execution field; scope spec locator (#7607) Two CI fixes for PR #7720: 1. UpdatePage.tsx — optional-chain us.execution.status. Integration test stubs (update-banner.spec.ts) ship payloads without the Tier 2/3 execution / lastResult / lockHeld fields; without optional chaining on the new scheduled-derivation line the whole page crashed before the h1 rendered, breaking the unrelated "renders current version" test. 2. update-scheduled.spec.ts — scope the v2.7.2 assertion to the .update-scheduled section. The regex was matching three elements (banner, countdown panel, changelog link) and tripped Playwright's strict-mode locator check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(updater): address Qodo review (Tier 3 race conditions + tier-off bypass) (#7607) Four fixes for bugs flagged by Qodo's review of PR #7720: 1. **Tier=off bypasses scheduler** (correctness). expressCreateServer used to instantiate the scheduler and rehydrate any persisted `scheduled` state regardless of `updates.tier`. A user who set `tier: "off"` after a schedule had been persisted would still see the timer fire after restart. The boot path now skips scheduler creation when tier is off and explicitly clears a stale scheduled state to idle (logged so the admin sees what happened). 2. **Timer fire skips state recheck** (reliability). The scheduler's timer callback called applyUpdate() directly. Race: admin clicks Cancel at the same instant the timer fires, or the tier flips during the grace window. Now schedulerTriggerApply re-loads state and re-evaluates policy via a new pure decideTriggerApply() helper in Scheduler.ts. If state is no longer scheduled (or scheduled for a different tag), aborts. If policy now denies auto, persists state back to idle and aborts. 3. **Apply-now leaves scheduler timer armed** (correctness). The apply endpoint accepts `scheduled` as an entry status but didn't cancel the in-process scheduler timer. After the admin clicks Apply now, the still-armed timer could later fire and attempt another apply (especially if the manual one finishes in preflight-failed, which is also an allowed-entry status). Apply handler now calls cancelScheduler() when entering from `scheduled`. 4. **scheduledFor not validated as timestamp** (reliability). State validator only required scheduledFor / startedAt etc. to be non-empty strings; a hand-edited "scheduledFor": "garbage" would pass validation and yield NaN delay → immediate fire. The validator now requires known timestamp fields to be parseable via Date.parse(). Tests: 6 new decideTriggerApply cases + 3 new state.ts validation cases. 189 vitest pass / 29 mocha integration pass / ts-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.7 KiB
Etherpad Auto-Update — Manual Smoke Runbook
Status: required gate before each tier ships, per 2026-04-25-auto-update-design.md § "Phased rollout".
Audience: the engineer cutting a release that includes new updater code.
Time budget: ~30–40 minutes for the full sweep against a disposable VM.
This runbook exercises the failure paths that unit and integration tests cannot reach: a real process supervisor, a real pnpm install run, real session drain broadcasts to a real pad client. Run it on a throw-away VM you don't mind nuking.
0. Provision a disposable VM
Anything Linux works; the example below uses Debian/Ubuntu under systemd.
# On the VM
sudo adduser --system --group --home /srv/etherpad --shell /bin/bash etherpad
sudo apt update && sudo apt install -y git nodejs ca-certificates
# Etherpad's pnpm comes from corepack — Node 22+ ships it.
sudo -u etherpad bash -c '
cd /srv/etherpad
git clone https://github.com/ether/etherpad.git current
cd current
corepack enable && corepack prepare pnpm@latest-9 --activate
pnpm install
pnpm run build:ui
'
1. Install Etherpad as a systemd service
/etc/systemd/system/etherpad.service:
[Unit]
Description=Etherpad
After=network.target
[Service]
Type=simple
User=etherpad
WorkingDirectory=/srv/etherpad/current
ExecStart=/usr/bin/pnpm run dev
Restart=on-failure
RestartSec=5
SuccessExitStatus=75
# Treat exit 75 as "intentional" so systemd doesn't escalate-restart counters.
[Install]
WantedBy=multi-user.target
Then:
sudo systemctl daemon-reload
sudo systemctl enable --now etherpad
journalctl -u etherpad -f & # tail the log in another terminal
2. Configure for Tier 2
Edit /srv/etherpad/current/settings.json and set:
{
"updates": {
"tier": "manual",
"checkIntervalHours": 1,
"drainSeconds": 30, // shorten the wait during smoke testing
"rollbackHealthCheckSeconds": 30
}
}
sudo systemctl restart etherpad. Visit http://<vm-ip>:9001/admin/update and log in as the admin user from settings.json.
3. Force "an update is available"
The simplest way: git checkout to a commit before a tagged release.
sudo -u etherpad bash -c 'cd /srv/etherpad/current && git checkout v2.7.2'
sudo systemctl restart etherpad
Trigger an immediate version check (or wait an hour):
curl -fsSL http://localhost:9001/admin/update/status | jq .
# Expect: latest.version newer than currentVersion, policy.canManual=true
The admin UI banner should now read "Update available", and /admin/update should show an "Apply update" button.
4. Happy path: apply, drain, restart, verify
- Open a pad in another browser tab (
http://<vm-ip>:9001/p/test). - Click Apply update on
/admin/update. - Within 30 seconds confirm:
- The pad shows a gritter notification "Etherpad will restart in 30 seconds…" (i18n string from
update.drain.t30), thenupdate.drain.t10. - The page polls
/admin/update/log; the<pre>block fills withgit fetch / checkout / pnpm install / pnpm run build:uioutput.
- The pad shows a gritter notification "Etherpad will restart in 30 seconds…" (i18n string from
- systemd journal shows
update executed: <fromSha> -> <tag>; exiting 75 for supervisor restart. - systemd restarts the unit (~5s under
RestartSec). - Reload
/admin/update. State should beverifiedwithlastResult.outcome: "verified".
Sign-off: every observable transition matches the state machine in the design spec § "State machine". If any step lingers or the page shows a different status, capture var/log/update.log and stop.
5. Rollback path: install failure
Force a rollback by giving pnpm something it can't resolve.
# As etherpad user, in /srv/etherpad/current:
git checkout v2.7.2
echo 'lockfileVersion: this-is-not-real-content' >> pnpm-lock.yaml
sudo systemctl restart etherpad
Visit /admin/update and click Apply.
Expected:
- Drain announcement on the pad as before.
- Log shows
pnpm install --frozen-lockfileexiting non-zero. - State goes through
rolling-back→rolled-back. - After supervisor restart,
/admin/updateshows the rolled-back banner withlastResult.reasondescribing the install failure. git rev-parse HEADmatches the pre-update SHA.- Click Acknowledge to clear the lastResult banner.
6. Rollback path: build failure
git checkout v2.7.2
# Break the build by introducing a syntax error:
echo 'this is not valid TypeScript' >> src/static/js/pad.ts
sudo systemctl restart etherpad # confirm the broken tree still serves; we want apply to fail at build:ui, not at boot
Apply, observe pnpm run build:ui exit non-zero in the log, observe rolling-back → rolled-back. Working tree restored.
Revert the syntax error before continuing.
7. Crash-loop guard
Force the new version to crash at boot more than twice. Easiest:
# As etherpad user:
git checkout v2.7.2
# Apply to v2.7.3, but during the apply window introduce a startup error:
# (Edit src/node/server.ts in the v2.7.3 tag's worktree to throw immediately.)
Click Apply. The new boot crashes; systemd restarts; RollbackHandler increments bootCount. After three crashes, bootCount > 2 triggers a forced rollback regardless of the health-check timer.
Observe state lands on rolled-back with reason: "health-check-failed-or-crash-loop". Working tree on the original SHA.
8. Rollback-failed terminal state
Hardest to set up; force pnpm install to fail on the rollback path too.
# Trigger a normal install-failed rollback (step 5), but BEFORE it runs the
# rollback step, corrupt the backup lockfile:
echo garbage > /srv/etherpad/current/var/update-backup/pnpm-lock.yaml
# … or remove the etherpad user's permission to the install dir mid-flow.
Expected:
- State lands on
rollback-failed. /admin/updateshows the strong red banner (role=alert) with theupdate.banner.terminal.rollback-failedcopy.policy.canManualstays true;policy.canAutois false (terminal-blocked).- Manually fix the install (restore the lockfile, fix permissions), then
click Acknowledge. State returns to
idleand Apply re-enables.
9. Cancel during drain
Click Apply. Within 30s, click Cancel.
Expected:
- Drain timers stop firing immediately.
- State returns to
idle. lastResult.outcome: "cancelled".var/update.lockis gone.- No exit; systemd doesn't restart.
10. Sign-off checklist
Tick every line before approving the release that introduces this code:
- Happy path lands on
verifiedwith the working tree on the new tag. - Install-fail and build-fail rollbacks restore the previous SHA.
- Crash-loop guard forces rollback at
bootCount > 2. rollback-failedshows the strong banner and Acknowledge clears it.- Cancel during drain leaves no lock, returns to
idle. - Pad client renders the localised drain announcement (NOT the literal i18n key).
- systemd journal shows no unhandled rejections, no orphaned processes.
var/log/update.logis rotated when it crosses 10 MB (force this by writing >10 MB into the file and triggering an Apply).
If any line is unticked, do not ship the release.
11. Tier 3 — grace window, scheduled apply, cancel, restart-in-grace
Configure the VM for tier 3:
{
"updates": {
"tier": "auto",
"preApplyGraceMinutes": 2, // short for smoke
"drainSeconds": 15,
"checkIntervalHours": 1
}
}
-
As in §3,
git checkout v2.7.2. Restart Etherpad. Wait for the immediate first version check (~5s after boot). -
Confirm a schedule was created:
curl -fsSL http://localhost:9001/admin/update/status | jq '.execution' # Expect: {"status":"scheduled","targetTag":"v...","scheduledFor":"...","startedAt":"..."} -
Visit
/admin/update. Confirm:- A countdown panel renders with the localised "Etherpad will start updating to vX.Y.Z in Nm Ms." copy.
- Two buttons: Cancel and Apply now.
-
Happy path: wait for the timer to fire. The same flow as §4 (drain, executor, exit 75) runs. State lands on
verifiedafter the supervisor restarts on the new version. -
Cancel path: repeat steps 1–3 in a fresh setup. Click Cancel during the countdown. Expected:
- State transitions to
idle;lastResult.outcome: "cancelled". journalctl -u etherpadshowscancelled pending schedule (admin-cancellation).- The next version check (within
checkIntervalHours) re-schedules the same release — this is correct: the schedule was cancelled but the policy still wants the update. To opt out completely, setupdates.tier: "notify"instead.
- State transitions to
-
Apply-now path: repeat steps 1–3. Click Apply now. The regular Tier 2 pipeline starts immediately; the previously-armed timer is harmlessly stale (the executor takes over before it fires).
-
Restart-in-grace path: repeat steps 1–3, then
sudo systemctl restart etherpadmid-countdown. On boot, the journal logsupdater: rehydrating Tier 3 schedule for vX.Y.Z at ..../admin/updateresumes the countdown from the persistedscheduledFor(no re-arming, no re-emailing). -
Email path (if
adminEmailis set): the journal logs(would send email) ... [Etherpad] Auto-update scheduled for ...exactly once per scheduled tag. Re-arming for the same tag does not re-email. A different tag arming over the top does.
If any step diverges, capture var/log/update.log and stop. Add to the §10 sign-off checklist:
- Tier 3 schedule transitions execution →
scheduledafter the version check. - Countdown panel renders the localised string (not the i18n key).
- Cancel during scheduled returns to
idle. - Apply now during scheduled runs the Tier 2 pipeline immediately.
- Restart-in-grace rehydrates the timer.
grace-startemail fires once per tag whenadminEmailis set.