diff --git a/CLAUDE.md b/CLAUDE.md index 1c85935a..881a383f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`. -**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable. +**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable. **Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`). diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 8b1e085b..f78f3a5c 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -31,6 +31,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" REPO="" TAG="" SUPERVISOR="none" +SERVER_PID="" STATUS_FILE="" UPDATE_ID="" FROM_VERSION="" @@ -50,6 +51,7 @@ while [[ $# -gt 0 ]]; do --node) NODE="$2"; shift 2 ;; --log) LOG="$2"; shift 2 ;; --prev-sha) PREV_SHA="$2"; shift 2 ;; + --server-pid) SERVER_PID="$2"; shift 2 ;; --stash) DO_STASH=1; shift ;; *) shift ;; esac @@ -198,6 +200,19 @@ case "$SUPERVISOR" in || fail "Build succeeded but launchd restart failed" "launchctl" } ;; + launchd-daemon) + # System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system + # domain needs root, but we don't need it — kill the server and launchd + # respawns it on the new dist/ within ThrottleInterval seconds. + if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then + : # respawn is launchd's job from here + else + MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web" + write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION." + echo "[self-update] launchd-daemon: could not signal server pid '$SERVER_PID' — manual restart required" + exit 0 + fi + ;; *) MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &" write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION." diff --git a/src/types/update.ts b/src/types/update.ts index adefc953..289b3e4a 100644 --- a/src/types/update.ts +++ b/src/types/update.ts @@ -13,8 +13,12 @@ * @module types/update */ -/** Which init system supervises the running server (decides how we restart it). */ -export type SupervisorKind = 'systemd' | 'launchd' | 'none'; +/** + * Which init system supervises the running server (decides how we restart it). + * `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI + * login): restart works by killing the server and letting launchd respawn it. + */ +export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none'; /** How Codeman was installed — only `git` installs can self-update in place. */ export type InstallKind = 'git' | 'npm' | 'unknown'; diff --git a/src/web/self-update.ts b/src/web/self-update.ts index cd7a505e..2b85488a 100644 --- a/src/web/self-update.ts +++ b/src/web/self-update.ts @@ -161,7 +161,9 @@ export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: strin * persist — or null to leave it untouched. * * Rules (see plan "Hardening"): - * - Terminal phases → untouched. + * - Terminal phases → untouched, EXCEPT `completed-needs-manual-restart`: once we + * boot into the staged target version the manual restart evidently happened, so + * it flips to `completed` (otherwise the stale instruction lingers in the UI). * - Only the `restarting` marker (written right before the updater triggers our * restart) flips to completed/failed by comparing running version vs. target. * - Other in-flight phases are owned by the still-running updater scope — leave @@ -174,6 +176,17 @@ export function reconcileStatusDecision( now: number ): UpdateStatus | null { if (!status) return null; + + // A staged update that asked for a manual restart: if we're now running the + // target version, the user (or supervisor) did restart — mark it completed so + // the UI stops showing the stale "restart Codeman to apply" instruction. + if (status.phase === 'completed-needs-manual-restart') { + if (status.toVersion && runningVersion === status.toVersion) { + return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now }; + } + return null; + } + if (!IN_FLIGHT_PHASES.has(status.phase)) return null; if (status.phase === 'restarting') { @@ -275,6 +288,16 @@ function detectInstallKind(dir: string): InstallKind { export function detectSupervisor(): SupervisorKind { if (process.platform === 'darwin') { if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd'; + // Headless Macs (no GUI login → no gui domain) run Codeman as a system-level + // LaunchDaemon instead. Restarting one needs no root IF it has KeepAlive: the + // updater just kills the server and launchd respawns it on the new build. Only + // claim this supervisor when the daemon is actually bootstrapped and KeepAlive. + const daemonPlist = join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`); + if (existsSync(daemonPlist)) { + const loaded = tryExec('launchctl', ['print', `system/${LAUNCHD_LABEL}`]) !== null; + const keepAlive = tryExec('plutil', ['-extract', 'KeepAlive', 'raw', '-o', '-', daemonPlist]); + if (loaded && keepAlive === 'true') return 'launchd-daemon'; + } return 'none'; } if (process.platform === 'linux') { @@ -535,6 +558,10 @@ export async function startUpdate(): Promise { process.execPath, '--log', logFile, + // For the launchd-daemon restart path: the updater kills this PID and the + // KeepAlive daemon respawns the server on the freshly built dist/. + '--server-pid', + String(process.pid), ]; if (prevSha) args.push('--prev-sha', prevSha); if (info.dirty) args.push('--stash'); diff --git a/test/self-update.test.ts b/test/self-update.test.ts index 16d2bf40..7a928faf 100644 --- a/test/self-update.test.ts +++ b/test/self-update.test.ts @@ -153,4 +153,17 @@ describe('reconcileStatusDecision (boot handoff state machine)', () => { expect(out?.phase).toBe('failed'); expect(out?.error).toContain('building'); }); + + it('needs-manual-restart + now running the target version → completed', () => { + const out = reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.4', NOW); + expect(out?.phase).toBe('completed'); + expect(out?.message).toContain('0.9.4'); + expect(out?.updatedAt).toBe(NOW); + }); + + it('needs-manual-restart + still on the old version → untouched (restart pending)', () => { + expect(reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.3', NOW)).toBeNull(); + const noTarget = base({ phase: 'completed-needs-manual-restart', toVersion: undefined }); + expect(reconcileStatusDecision(noTarget, '0.9.4', NOW)).toBeNull(); + }); });