diff --git a/scripts/self-update.sh b/scripts/self-update.sh new file mode 100755 index 00000000..8dbc79f8 --- /dev/null +++ b/scripts/self-update.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# +# self-update.sh — apply a Codeman release update from inside the running app. +# +# Spawned DETACHED by the web server (POST /api/system/update → src/web/self-update.ts). +# It outlives the service restart it triggers, so it MUST run from a copy OUTSIDE +# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git +# checkout` rewrites the in-repo copy and bash reads scripts lazily. +# +# Reports progress by writing ~/.codeman/update-status.json atomically; the +# browser polls GET /api/system/update/status across the restart drop. The +# freshly-booted server reconciles the final "restarting" → "completed"/"failed". +# +# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a +# manual command (foreground installs). Linux launches inside a transient +# systemd scope so `systemctl restart codeman-web` can't kill it mid-build. +# +# Args (all from the server, never user input — tag is validated server-side): +# --repo --tag --supervisor +# --status-file --update-id --from-version --node +# --log [--prev-sha ] [--stash] +# +set -uo pipefail + +REPO="" +TAG="" +SUPERVISOR="none" +STATUS_FILE="" +UPDATE_ID="" +FROM_VERSION="" +NODE="node" +LOG="/dev/null" +PREV_SHA="" +DO_STASH=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) REPO="$2"; shift 2 ;; + --tag) TAG="$2"; shift 2 ;; + --supervisor) SUPERVISOR="$2"; shift 2 ;; + --status-file) STATUS_FILE="$2"; shift 2 ;; + --update-id) UPDATE_ID="$2"; shift 2 ;; + --from-version) FROM_VERSION="$2"; shift 2 ;; + --node) NODE="$2"; shift 2 ;; + --log) LOG="$2"; shift 2 ;; + --prev-sha) PREV_SHA="$2"; shift 2 ;; + --stash) DO_STASH=1; shift ;; + *) shift ;; + esac +done + +# All output → the log file (the process is detached, no tty). +exec >>"$LOG" 2>&1 || true +echo "[self-update] $(date) start tag=$TAG supervisor=$SUPERVISOR repo=$REPO" + +# Make node/npm/git reachable regardless of the (possibly minimal) service env. +export PATH="$(dirname "$NODE"):$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:$PATH" +export GIT_TERMINAL_PROMPT=0 + +TO_VERSION="${TAG##*@}" # codeman@0.9.4 → 0.9.4 (tag is validated upstream) +STASH_REF="" +MANUAL_CMD="" + +# Write the status file atomically via node (valid JSON, preserves startedAt). +write_status() { + local phase="$1" message="$2" err="${3:-}" + STATUS_FILE="$STATUS_FILE" UPDATE_ID="$UPDATE_ID" PHASE="$phase" MESSAGE="$message" \ + FROM_VERSION="$FROM_VERSION" TO_VERSION="$TO_VERSION" TO_TAG="$TAG" PREV_SHA="$PREV_SHA" \ + STASH_REF="$STASH_REF" SUPERVISOR="$SUPERVISOR" ERROR="$err" MANUAL_CMD="$MANUAL_CMD" \ + "$NODE" -e ' + const fs = require("fs"); + const f = process.env.STATUS_FILE; + let started = 0; + try { const cur = JSON.parse(fs.readFileSync(f, "utf8")); if (cur && cur.startedAt) started = cur.startedAt; } catch {} + const s = { + updateId: process.env.UPDATE_ID, + phase: process.env.PHASE, + message: process.env.MESSAGE, + fromVersion: process.env.FROM_VERSION, + startedAt: started, + updatedAt: Date.now(), + }; + if (process.env.TO_VERSION) s.toVersion = process.env.TO_VERSION; + if (process.env.TO_TAG) s.toTag = process.env.TO_TAG; + if (process.env.PREV_SHA) s.prevSha = process.env.PREV_SHA; + s.stashRef = process.env.STASH_REF || null; + if (process.env.SUPERVISOR) s.supervisor = process.env.SUPERVISOR; + if (process.env.ERROR) s.error = process.env.ERROR; + if (process.env.MANUAL_CMD) s.manualRestartCommand = process.env.MANUAL_CMD; + const tmp = f + ".tmp-" + process.pid; + fs.writeFileSync(tmp, JSON.stringify(s, null, 2)); + fs.renameSync(tmp, f); + ' || echo "[self-update] WARN: status write failed ($phase)" +} + +fail() { + local msg="$1" err="${2:-}" + echo "[self-update] FAILED: $msg ($err)" + write_status "failed" "$msg" "$err" + exit 1 +} + +# Restore the previous commit + working build so the still-running server keeps +# serving good code. We do NOT restart on failure. +rollback_and_fail() { + local msg="$1" + echo "[self-update] $msg — rolling back to ${PREV_SHA:-}" + if [[ -n "$PREV_SHA" ]]; then + git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true + npm install --no-fund --no-audit >/dev/null 2>&1 || true + npm run build >/dev/null 2>&1 || true + fi + fail "$msg — rolled back to the previous version" "$msg" +} + +cd "$REPO" || fail "Install directory not found" "cd $REPO" +git rev-parse --git-dir >/dev/null 2>&1 || fail "Not a git repository" "$REPO" + +write_status "preparing" "Preparing update to v$TO_VERSION…" + +# 1) Stash local changes (left for the user to pop — never auto-popped). +if [[ "$DO_STASH" == "1" ]]; then + write_status "stashing" "Stashing local changes…" + STASH_MSG="codeman-pre-update-$UPDATE_ID" + if git stash push -u -m "$STASH_MSG" >/dev/null 2>&1; then + STASH_REF="$STASH_MSG" + echo "[self-update] stashed local changes as $STASH_MSG" + fi +fi + +# 2) Fetch the target tag. +write_status "fetching" "Fetching $TAG…" +git fetch --tags --force origin "refs/tags/$TAG:refs/tags/$TAG" 2>/dev/null \ + || git fetch --tags --force origin \ + || fail "Could not fetch the release" "git fetch $TAG" + +# 3) Check out the release tag (detached HEAD at the release). +write_status "checkout" "Checking out $TAG…" +git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG" + +# 4) Install dependencies. +write_status "installing" "Installing dependencies…" +npm install --no-fund --no-audit || rollback_and_fail "Dependency install failed" + +# 5) Build (gate the restart on success — never restart into a torn dist/). +write_status "building" "Building…" +npm run build || rollback_and_fail "Build failed" + +# 6) Restart the service so the new code loads. Write the terminal pre-restart +# marker FIRST so the freshly-booted server can reconcile it deterministically. +write_status "restarting" "Restarting Codeman…" +echo "[self-update] build OK, restarting via $SUPERVISOR" + +case "$SUPERVISOR" in + systemd) + systemctl --user restart codeman-web.service \ + || fail "Build succeeded but restart failed — run: systemctl --user restart codeman-web" "systemctl restart" + ;; + launchd) + launchctl kickstart -k "gui/$(id -u)/com.codeman.web" 2>/dev/null || { + PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist" + launchctl unload "$PLIST" 2>/dev/null || true + launchctl load "$PLIST" 2>/dev/null \ + || fail "Build succeeded but launchd restart failed" "launchctl" + } + ;; + *) + MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &" + write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION." + echo "[self-update] no supervisor — manual restart required" + exit 0 + ;; +esac + +echo "[self-update] restart issued; done" +exit 0 diff --git a/src/types/index.ts b/src/types/index.ts index 6420817a..22a7369c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,3 +66,4 @@ export * from './teams.js'; export * from './push.js'; export * from './plan.js'; export * from './orchestrator.js'; +export * from './update.js'; diff --git a/src/types/update.ts b/src/types/update.ts new file mode 100644 index 00000000..adefc953 --- /dev/null +++ b/src/types/update.ts @@ -0,0 +1,96 @@ +/** + * @fileoverview Types for the in-app self-updater. + * + * Codeman can update itself from the web UI (App Settings → Updates). The flow + * is driven by a detached `scripts/self-update.sh` that outlives the service + * restart it triggers, and a status file at `~/.codeman/update-status.json` + * (see `dataPath('update-status.json')`) that the browser polls across the + * restart boundary. + * + * Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts` + * (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`). + * + * @module types/update + */ + +/** Which init system supervises the running server (decides how we restart it). */ +export type SupervisorKind = 'systemd' | 'launchd' | 'none'; + +/** How Codeman was installed — only `git` installs can self-update in place. */ +export type InstallKind = 'git' | 'npm' | 'unknown'; + +/** + * Lifecycle of a single update run. `idle`/`completed`/`failed`/ + * `completed-needs-manual-restart` are terminal; the rest are in-flight. + */ +export type UpdatePhase = + | 'idle' + | 'queued' + | 'preparing' + | 'stashing' + | 'fetching' + | 'checkout' + | 'installing' + | 'building' + | 'restarting' + | 'completed' + | 'completed-needs-manual-restart' + | 'failed'; + +/** Persisted update progress, written atomically by the updater + boot reconcile. */ +export interface UpdateStatus { + /** Nonce identifying this run; guards boot-reconcile against stale/foreign status. */ + updateId: string; + phase: UpdatePhase; + /** Human-readable one-liner for the UI. */ + message: string; + /** Version the server was on when the update started. */ + fromVersion: string; + /** Target version (parsed from the release tag). */ + toVersion?: string; + /** Target git tag, e.g. `codeman@0.9.4`. */ + toTag?: string; + /** Commit the repo was on before the update, for rollback. */ + prevSha?: string; + /** Name of the stash holding local changes (when the tree was dirty), else null. */ + stashRef?: string | null; + supervisor?: SupervisorKind; + /** epoch ms — update start (freshness guard for boot reconcile). */ + startedAt: number; + /** epoch ms — last write. */ + updatedAt: number; + /** Populated on failure. */ + error?: string; + /** Shown for the `none` supervisor — the command the user must run by hand. */ + manualRestartCommand?: string; +} + +/** Describes the running install — drives whether/how the Updates UI is shown. */ +export interface InstallInfo { + installKind: InstallKind; + installDir: string; + /** Current git branch, or `HEAD` when detached (e.g. pinned to a release tag). */ + branch?: string; + /** Uncommitted local changes present (true → updater will auto-stash). */ + dirty: boolean; + supervisor: SupervisorKind; + currentVersion: string; + /** False when `CODEMAN_DISABLE_SELF_UPDATE=1`. */ + selfUpdateEnabled: boolean; +} + +/** Result of "check for updates" — current vs. latest release. */ +export interface UpdateCheckResult { + currentVersion: string; + latestVersion: string | null; + latestTag: string | null; + updateAvailable: boolean; + /** Release notes (markdown) when available from the GitHub API. */ + notes?: string | null; + /** Link to the release page. */ + htmlUrl?: string | null; + /** epoch ms of the check. */ + checkedAt: number; + source: 'github-api' | 'git-ls-remote' | 'none'; + error?: string; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index 9d98137a..54c1c424 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1078,6 +1078,24 @@ + +
Updates
+
+ Current Version + — +
+
+ Check for Updates + +
+ + + + + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8f1cc2a6..8d714538 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -430,6 +430,9 @@ Object.assign(CodemanApp.prototype, { providerEl.textContent = providerName; providerEl.className = 'voice-provider-status' + (providerName.startsWith('Deepgram') ? ' active' : ''); + // Updates section — show current version, reset transient result/progress UI. + this._initUpdatesSection(); + // Reset to first tab and wire up tab switching this.switchSettingsTab('settings-display'); const modal = document.getElementById('appSettingsModal'); @@ -465,6 +468,177 @@ Object.assign(CodemanApp.prototype, { } }, + // ─────────────────────────────────────────────────────────────── + // Self-Update (App Settings → Updates). Backend: src/web/self-update.ts. + // ─────────────────────────────────────────────────────────────── + + /** Friendly label for an in-flight update phase. */ + _updatePhaseText(phase) { + return { + queued: 'Queued…', + preparing: 'Preparing…', + stashing: 'Stashing local changes…', + fetching: 'Fetching release…', + checkout: 'Checking out release…', + installing: 'Installing dependencies…', + building: 'Building…', + restarting: 'Restarting Codeman…', + }[phase] || phase; + }, + + /** Populate the version row and clear transient UI when the modal opens. */ + _initUpdatesSection() { + const verEl = this.$('updateCurrentVersion'); + if (verEl) verEl.textContent = (this.$('versionDisplay')?.textContent || '').trim() || '—'; + for (const id of ['updateResult', 'updateActionRow', 'updateNotes', 'updateProgress']) { + const el = this.$(id); + if (el) el.style.display = 'none'; + } + this._updateCheck = null; + }, + + _setUpdateResult(html) { + const el = this.$('updateResult'); + if (el) { el.style.display = 'block'; el.innerHTML = html; } + }, + + _setUpdateProgress(html) { + const el = this.$('updateProgress'); + if (el) { el.style.display = 'block'; el.innerHTML = html; } + }, + + /** Manual "Check for updates" — asks the server to query GitHub. */ + async checkForUpdate() { + const btn = this.$('updateCheckBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; } + const data = await this._apiJson('/api/system/update/check'); + if (btn) { btn.disabled = false; btn.textContent = 'Check now'; } + + const actionRow = this.$('updateActionRow'); + const notes = this.$('updateNotes'); + if (actionRow) actionRow.style.display = 'none'; + if (notes) notes.style.display = 'none'; + + if (!data) { + this._setUpdateResult('Could not check for updates. Try again later.'); + return; + } + this._updateCheck = data; + const verEl = this.$('updateCurrentVersion'); + if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`; + + if (data.installKind && data.installKind !== 'git') { + this._setUpdateResult( + `This install can't update itself (${escapeHtml(data.installKind)}). Update with npm i -g aicodeman@latest.` + ); + return; + } + if (data.selfUpdateEnabled === false) { + this._setUpdateResult('In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).'); + return; + } + if (data.error && !data.updateAvailable) { + this._setUpdateResult(escapeHtml(data.error)); + return; + } + if (data.updateAvailable && data.latestVersion) { + this._setUpdateResult( + `Update available: v${escapeHtml(data.latestVersion)}  (current v${escapeHtml(data.currentVersion || '')})` + ); + const label = this.$('updateActionLabel'); + if (label) label.textContent = `Update to v${data.latestVersion}`; + if (actionRow) actionRow.style.display = 'flex'; + const nowBtn = this.$('updateNowBtn'); + if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Update now'; } + if (notes && data.notes) { + notes.style.display = 'block'; + notes.textContent = data.notes; + } + } else { + this._setUpdateResult(`You're up to date (v${escapeHtml(data.currentVersion || '')}).`); + } + }, + + /** Start the update, then poll status across the service restart. */ + async startSelfUpdate() { + const target = this._updateCheck?.latestVersion ? `v${this._updateCheck.latestVersion}` : 'the latest release'; + if (!confirm(`Update Codeman to ${target}? The server will restart and this page will reload.`)) return; + + const btn = this.$('updateNowBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; } + const res = await this._apiPost('/api/system/update', {}); + if (!res || !res.ok) { + let msg = 'Failed to start the update.'; + try { const j = await res.json(); if (j?.error?.message) msg = j.error.message; } catch {} + this._setUpdateProgress(`${escapeHtml(msg)}`); + if (btn) { btn.disabled = false; btn.textContent = 'Update now'; } + return; + } + const actionRow = this.$('updateActionRow'); + if (actionRow) actionRow.style.display = 'none'; + const notes = this.$('updateNotes'); + if (notes) notes.style.display = 'none'; + this._setUpdateProgress('Starting update…'); + this._pollUpdateStatus(); + }, + + _stopUpdatePolling() { + if (this._updatePollTimer) { clearInterval(this._updatePollTimer); this._updatePollTimer = null; } + }, + + /** + * Poll the status file every 1.5s. Survives the connection drop while the + * server restarts (fetch throws → "restarting"), then reads the reconciled + * terminal state from the freshly-booted server. + */ + _pollUpdateStatus() { + this._stopUpdatePolling(); + const terminal = new Set(['completed', 'completed-needs-manual-restart', 'failed', 'idle']); + const poll = async () => { + let data = null; + try { + const res = await fetch('/api/system/update/status'); + if (res.ok) data = await res.json(); + } catch { /* server restarting — keep polling */ } + + if (!data) { + this._setUpdateProgress('↻ Restarting Codeman…'); + return; + } + if (!terminal.has(data.phase)) { + this._setUpdateProgress(`↻ ${escapeHtml(this._updatePhaseText(data.phase))}`); + return; + } + this._stopUpdatePolling(); + if (data.phase === 'completed') { + let html = `✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…`; + if (data.stashRef) { + html += `
Local changes stashed as ${escapeHtml(data.stashRef)} — run git stash pop to restore.`; + } + this._setUpdateProgress(html); + setTimeout(() => location.reload(), 2500); + } else if (data.phase === 'completed-needs-manual-restart') { + this._setUpdateProgress( + `Update staged. Restart Codeman to apply:
${escapeHtml(data.manualRestartCommand || 'restart codeman web')}` + ); + } else if (data.phase === 'failed') { + let html = `✗ ${escapeHtml(data.message || 'Update failed')}.`; + if (data.error) html += `
${escapeHtml(data.error)}`; + html += `
The previous version is still running.`; + if (data.stashRef) { + html += `
Local changes stashed as ${escapeHtml(data.stashRef)}.`; + } + this._setUpdateProgress(html); + const nowBtn = this.$('updateNowBtn'); + const actionRow = this.$('updateActionRow'); + if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Try again'; } + if (actionRow) actionRow.style.display = 'flex'; + } + }; + poll(); + this._updatePollTimer = setInterval(poll, 1500); + }, + async loadTunnelStatus() { try { const res = await fetch('/api/tunnel/status'); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 2c72f63e..a472f599 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -35,6 +35,7 @@ import { SETTINGS_PATH, } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; +import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; @@ -293,6 +294,39 @@ export function registerSystemRoutes( } }); + // ═══════════════════════════════════════════════════════════════ + // Self-Update (App Settings → Updates) + // ═══════════════════════════════════════════════════════════════ + + // Install info + whether a newer release exists. Manual, user-triggered. + app.get('/api/system/update/check', async () => { + const check = await checkForUpdate(); + const info = getInstallInfo(); + return { ...info, ...check }; + }); + + // Poll target for update progress — survives the restart the update triggers. + app.get('/api/system/update/status', async () => getUpdateStatusForApi()); + + // Kick off a detached update to the latest release. Returns immediately; the + // browser then polls /api/system/update/status across the service restart. + app.post('/api/system/update', async (_req, reply) => { + const result = await startUpdate(); + if (result.ok) { + return { success: true, updateId: result.updateId, toTag: result.toTag, toVersion: result.toVersion }; + } + const map = { + 'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, + 'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, + 'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT }, + disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT }, + 'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT }, + error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR }, + } as const; + const m = map[result.code]; + return reply.code(m.http).send(createErrorResponse(m.api, result.message)); + }); + // ═══════════════════════════════════════════════════════════════ // CLI Integrations (OpenCode) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/self-update.ts b/src/web/self-update.ts new file mode 100644 index 00000000..cd7a505e --- /dev/null +++ b/src/web/self-update.ts @@ -0,0 +1,558 @@ +/** + * @fileoverview Server-side logic for the in-app self-updater. + * + * Powers App Settings → Updates. Codeman is installed as a git clone and run + * under systemd (Linux) or launchd (macOS); updating means `git checkout && npm install && npm run build && restart-the-service`. The hard part is + * that the update restarts the very process performing it, so the actual work + * runs in a DETACHED `scripts/self-update.sh` that outlives the restart, writing + * progress to `dataPath('update-status.json')` which the browser polls across the + * connection drop. + * + * Channel: latest tagged RELEASE (tags look like `codeman@0.9.3`). Dirty trees + * are auto-stashed (stash left for the user). Detection is manual (a button). + * + * Split into PURE helpers (semver/tag parsing, reconcile decision) that are unit + * tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`, + * `reconcileUpdateOnBoot`) that touch git/network/fs. + * + * Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in + * `src/web/routes/system-routes.ts`. + * + * @module web/self-update + */ + +import { spawn, execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir, tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { dataPath } from '../config/instance.js'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import type { + InstallInfo, + InstallKind, + SupervisorKind, + UpdateCheckResult, + UpdatePhase, + UpdateStatus, +} from '../types/update.js'; + +const require = createRequire(import.meta.url); +const { version: APP_VERSION } = require('../../package.json') as { version: string }; + +/** systemd unit name (matches install.sh + scripts/codeman-web.service). */ +const SYSTEMD_UNIT = 'codeman-web.service'; +/** launchd agent label (matches install.sh setup_launchd_service). */ +const LAUNCHD_LABEL = 'com.codeman.web'; +/** Path to the persisted update status file. */ +const STATUS_FILE = dataPath('update-status.json'); +/** Network/git timeout for the "check" path (longer than EXEC_TIMEOUT_MS — ls-remote hits the network). */ +const CHECK_TIMEOUT_MS = 12_000; +/** How long after `startedAt` a non-terminal status is treated as abandoned on boot. */ +const RECONCILE_STALE_MS = 15 * 60 * 1000; + +/** Phases that mean "an update is currently running". */ +const IN_FLIGHT_PHASES: ReadonlySet = new Set([ + 'queued', + 'preparing', + 'stashing', + 'fetching', + 'checkout', + 'installing', + 'building', + 'restarting', +]); + +export function isInFlight(status: UpdateStatus | null | undefined): boolean { + return !!status && IN_FLIGHT_PHASES.has(status.phase); +} + +// ───────────────────────────────────────────────────────────────────────────── +// PURE helpers (unit tested — no IO) +// ───────────────────────────────────────────────────────────────────────────── + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; + /** Non-empty for prereleases like `0.9.3-rc1`. */ + prerelease: string; +} + +/** + * Parse a semver out of a release tag. Accepts `codeman@0.9.3`, `aicodeman@0.9.3`, + * `v0.9.3`, and bare `0.9.3` (with optional `-prerelease`). Returns null if no + * `X.Y.Z` is present. + */ +export function parseVersionFromTag(tag: string): ParsedVersion | null { + const m = tag.trim().match(/(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\s*$/); + if (!m) return null; + return { + major: parseInt(m[1], 10), + minor: parseInt(m[2], 10), + patch: parseInt(m[3], 10), + prerelease: m[4] ?? '', + }; +} + +/** Compare two parsed versions. Returns >0 if a>b, <0 if a 0; +} + +/** + * From a list of `refs/tags/...` (or bare tag names), pick the highest STABLE + * release tag we recognize. Skips prereleases and unrecognized tags. + */ +export function pickLatestStableTag(tagRefs: string[]): { tag: string; version: string } | null { + let best: { tag: string; parsed: ParsedVersion } | null = null; + for (const raw of tagRefs) { + // Accept `refs/tags/codeman@0.9.3`, dereferenced `...^{}`, or bare tag names. + const tag = raw + .replace(/^.*refs\/tags\//, '') + .replace(/\^\{\}$/, '') + .trim(); + if (!tag) continue; + if (!/^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag) && !/^v?\d+\.\d+\.\d+$/.test(tag)) continue; + const parsed = parseVersionFromTag(tag); + if (!parsed || parsed.prerelease) continue; + if (!best || compareVersions(parsed, best.parsed) > 0) { + best = { tag, parsed }; + } + } + if (!best) return null; + return { tag: best.tag, version: `${best.parsed.major}.${best.parsed.minor}.${best.parsed.patch}` }; +} + +/** Tags must match this before they're ever passed to the shell. */ +export function isValidReleaseTag(tag: string): boolean { + return /^(codeman|aicodeman)@\d+\.\d+\.\d+$/.test(tag); +} + +/** Derive `{owner, repo}` from a GitHub SSH or HTTPS remote URL. */ +export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: string } | null { + const m = remoteUrl.trim().match(/github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/); + if (!m) return null; + return { owner: m[1], repo: m[2] }; +} + +/** + * PURE boot-time reconcile decision. Given the persisted status, the version the + * freshly-booted process is actually running, and `now`, return the status to + * persist — or null to leave it untouched. + * + * Rules (see plan "Hardening"): + * - Terminal phases → untouched. + * - 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 + * them alone so a normal/crash restart mid-update isn't misreported. + * - A backstop staleness guard fails any in-flight status older than the window. + */ +export function reconcileStatusDecision( + status: UpdateStatus | null, + runningVersion: string, + now: number +): UpdateStatus | null { + if (!status) return null; + if (!IN_FLIGHT_PHASES.has(status.phase)) return null; + + if (status.phase === 'restarting') { + if (status.toVersion && runningVersion === status.toVersion) { + return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now }; + } + return { + ...status, + phase: 'failed', + message: 'Restarted but version did not change', + error: `expected ${status.toVersion ?? '?'}, running ${runningVersion}`, + updatedAt: now, + }; + } + + // Not the restart marker: only intervene if clearly abandoned. + if (now - status.startedAt > RECONCILE_STALE_MS) { + return { + ...status, + phase: 'failed', + message: 'Update did not complete', + error: `abandoned during "${status.phase}"`, + updatedAt: now, + }; + } + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Status file IO +// ───────────────────────────────────────────────────────────────────────────── + +/** Read the persisted status; tolerant of a missing/torn file (returns null). */ +export function readUpdateStatus(): UpdateStatus | null { + try { + if (!existsSync(STATUS_FILE)) return null; + return JSON.parse(readFileSync(STATUS_FILE, 'utf-8')) as UpdateStatus; + } catch { + return null; + } +} + +/** Write the status atomically (temp + rename — readers never see a torn file). */ +export function writeUpdateStatusAtomic(status: UpdateStatus): void { + const tmp = `${STATUS_FILE}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(status, null, 2)); + renameSync(tmp, STATUS_FILE); +} + +/** Reconcile the status file on server boot (call once, early in start()). */ +export function reconcileUpdateOnBoot(now = Date.now()): void { + const status = readUpdateStatus(); + const next = reconcileStatusDecision(status, APP_VERSION, now); + if (next) writeUpdateStatusAtomic(next); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Environment probing (git / supervisor / install kind) +// ───────────────────────────────────────────────────────────────────────────── + +/** Run a command, returning trimmed stdout, or null on any error. */ +function tryExec(cmd: string, args: string[], cwd?: string, timeout = EXEC_TIMEOUT_MS): string | null { + try { + return execFileSync(cmd, args, { cwd, encoding: 'utf-8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return null; + } +} + +function commandExists(cmd: string): boolean { + return tryExec('sh', ['-c', `command -v ${cmd}`]) !== null; +} + +/** + * Resolve the repo root from this module's location. Compiled to + * `dist/web/self-update.js` (or `src/web/self-update.ts` under tsx) → two levels + * up is the package root that holds `package.json` and `.git`. Matches the + * `require('../../package.json')` resolution in `server.ts`. + */ +export function resolveInstallDir(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const root = join(moduleDir, '..', '..'); + if (existsSync(join(root, 'package.json'))) return root; + return process.cwd(); +} + +function detectInstallKind(dir: string): InstallKind { + if (existsSync(join(dir, '.git'))) return 'git'; + // Global npm install ships only dist/ (no src/, no .git). + if (!existsSync(join(dir, 'src'))) return 'npm'; + return 'unknown'; +} + +/** + * Detect which init system supervises us. Detection happens HERE (in the running + * server, which has a rich env) and the result is passed to the updater script — + * the detached child must not re-probe with a stripped-down environment. + */ +export function detectSupervisor(): SupervisorKind { + if (process.platform === 'darwin') { + if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd'; + return 'none'; + } + if (process.platform === 'linux') { + // INVOCATION_ID is set by systemd for service processes; confirm with is-active. + if (process.env.INVOCATION_ID && tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') { + return 'systemd'; + } + if (tryExec('systemctl', ['--user', 'is-active', SYSTEMD_UNIT]) === 'active') return 'systemd'; + } + return 'none'; +} + +function isSelfUpdateEnabled(): boolean { + return process.env.CODEMAN_DISABLE_SELF_UPDATE !== '1'; +} + +/** Inspect the running install: kind, dir, branch, dirtiness, supervisor, version. */ +export function getInstallInfo(): InstallInfo { + const installDir = resolveInstallDir(); + const installKind = detectInstallKind(installDir); + let branch: string | undefined; + let dirty = false; + if (installKind === 'git') { + branch = tryExec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], installDir) ?? undefined; + const porcelain = tryExec('git', ['status', '--porcelain'], installDir); + dirty = !!porcelain && porcelain.length > 0; + } + return { + installKind, + installDir, + branch, + dirty, + supervisor: detectSupervisor(), + currentVersion: APP_VERSION, + selfUpdateEnabled: isSelfUpdateEnabled(), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Update check (network) +// ───────────────────────────────────────────────────────────────────────────── + +async function fetchLatestReleaseFromGitHub( + owner: string, + repo: string +): Promise<{ tag: string; version: string; notes: string | null; htmlUrl: string | null } | null> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); + try { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, { + headers: { 'User-Agent': 'codeman-self-update', Accept: 'application/vnd.github+json' }, + signal: controller.signal, + }); + if (!res.ok) return null; + const data = (await res.json()) as { tag_name?: string; body?: string; html_url?: string }; + if (!data.tag_name) return null; + const parsed = parseVersionFromTag(data.tag_name); + if (!parsed || parsed.prerelease) return null; + return { + tag: data.tag_name, + version: `${parsed.major}.${parsed.minor}.${parsed.patch}`, + notes: data.body ?? null, + htmlUrl: data.html_url ?? null, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +function fetchLatestTagViaGit(installDir: string): { tag: string; version: string } | null { + const out = tryExec('git', ['ls-remote', '--tags', 'origin'], installDir, CHECK_TIMEOUT_MS); + if (!out) return null; + return pickLatestStableTag(out.split('\n').filter(Boolean)); +} + +/** Check the configured remote for a newer release than the running version. */ +export async function checkForUpdate(): Promise { + const info = getInstallInfo(); + const checkedAt = Date.now(); + const base: UpdateCheckResult = { + currentVersion: info.currentVersion, + latestVersion: null, + latestTag: null, + updateAvailable: false, + notes: null, + htmlUrl: null, + checkedAt, + source: 'none', + }; + if (info.installKind !== 'git') { + return { ...base, error: 'Not a git install — self-update is unavailable.' }; + } + + const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir); + const gh = remote ? parseGitHubRepo(remote) : null; + + if (gh) { + const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo); + if (rel) { + return { + ...base, + latestVersion: rel.version, + latestTag: rel.tag, + notes: rel.notes, + htmlUrl: rel.htmlUrl, + updateAvailable: isNewerStableVersion(info.currentVersion, rel.version), + source: 'github-api', + }; + } + } + + // Fallback: enumerate remote tags directly (works for non-GitHub remotes too). + const viaGit = fetchLatestTagViaGit(info.installDir); + if (viaGit) { + return { + ...base, + latestVersion: viaGit.version, + latestTag: viaGit.tag, + updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version), + source: 'git-ls-remote', + }; + } + + return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Start an update +// ───────────────────────────────────────────────────────────────────────────── + +export type StartUpdateResult = + | { ok: true; updateId: string; toTag: string; toVersion: string | null } + | { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string }; + +/** + * Copy the updater script OUT of the repo before running it. The script lives in + * the very repo it's about to `git checkout`, and bash reads scripts lazily — so + * running the in-repo copy risks executing torn/old-tag bytes after checkout. + * Run a snapshot under ~/.codeman instead (git never touches it). + */ +function stageRunner(installDir: string): string | null { + const src = join(installDir, 'scripts', 'self-update.sh'); + if (!existsSync(src)) return null; + const runner = dataPath('self-update-runner.sh'); + copyFileSync(src, runner); + chmodSync(runner, 0o755); + return runner; +} + +/** + * Launch the updater so it OUTLIVES the service restart it triggers. + * - Linux + systemd: a transient `--scope` cgroup, independent of the + * codeman-web service lifecycle (survives `systemctl restart` regardless of + * the unit's KillMode). Inherits our env so node/npm/git stay on PATH. + * - Everything else: `setsid` into a new session (escapes launchd's process-group + * kill); plain detached spawn as the last resort. + */ +function launchDetached(runner: string, args: string[]): void { + const useScope = process.platform === 'linux' && !!process.env.XDG_RUNTIME_DIR && commandExists('systemd-run'); + let cmd: string; + let cmdArgs: string[]; + if (useScope) { + cmd = 'systemd-run'; + cmdArgs = ['--user', '--scope', '--collect', '--quiet', 'bash', runner, ...args]; + } else if (commandExists('setsid')) { + cmd = 'setsid'; + cmdArgs = ['bash', runner, ...args]; + } else { + cmd = 'bash'; + cmdArgs = [runner, ...args]; + } + const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore', env: process.env }); + child.on('error', () => { + // Surface the failure in the status file so the UI doesn't hang on "queued". + const status = readUpdateStatus(); + if (status && isInFlight(status)) { + writeUpdateStatusAtomic({ + ...status, + phase: 'failed', + message: 'Could not launch the updater process', + error: `spawn ${cmd} failed`, + updatedAt: Date.now(), + }); + } + }); + child.unref(); +} + +/** + * Validate, snapshot the current commit, write the initial status, and spawn the + * detached updater. Returns immediately — progress is reported via the status file. + */ +export async function startUpdate(): Promise { + const info = getInstallInfo(); + if (!info.selfUpdateEnabled) { + return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' }; + } + if (info.installKind !== 'git') { + return { + ok: false, + code: 'not-git', + message: 'This is not a git install. Update with: npm i -g aicodeman@latest', + }; + } + const existing = readUpdateStatus(); + if (isInFlight(existing)) { + return { ok: false, code: 'in-flight', message: 'An update is already in progress.' }; + } + + const check = await checkForUpdate(); + if (!check.latestTag || !check.updateAvailable) { + return { ok: false, code: 'up-to-date', message: 'Already up to date.' }; + } + if (!isValidReleaseTag(check.latestTag)) { + return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` }; + } + + const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir); + const runner = stageRunner(info.installDir); + if (!runner) { + return { ok: false, code: 'error', message: 'scripts/self-update.sh not found in the install.' }; + } + + const updateId = randomUUID(); + const now = Date.now(); + const status: UpdateStatus = { + updateId, + phase: 'queued', + message: `Preparing update to v${check.latestVersion}…`, + fromVersion: info.currentVersion, + toVersion: check.latestVersion ?? undefined, + toTag: check.latestTag, + prevSha: prevSha ?? undefined, + stashRef: null, + supervisor: info.supervisor, + startedAt: now, + updatedAt: now, + }; + writeUpdateStatusAtomic(status); + + const logFile = join(tmpdir(), `codeman-update-${updateId}.log`); + const args = [ + '--repo', + info.installDir, + '--tag', + check.latestTag, + '--supervisor', + info.supervisor, + '--status-file', + STATUS_FILE, + '--update-id', + updateId, + '--from-version', + info.currentVersion, + '--node', + process.execPath, + '--log', + logFile, + ]; + if (prevSha) args.push('--prev-sha', prevSha); + if (info.dirty) args.push('--stash'); + + launchDetached(runner, args); + return { ok: true, updateId, toTag: check.latestTag, toVersion: check.latestVersion }; +} + +/** Current status for the polling endpoint; null collapses to an explicit idle. */ +export function getUpdateStatusForApi(): UpdateStatus { + const status = readUpdateStatus(); + if (status) return status; + return { + updateId: '', + phase: 'idle', + message: '', + fromVersion: APP_VERSION, + startedAt: 0, + updatedAt: 0, + }; +} diff --git a/src/web/server.ts b/src/web/server.ts index e9e6131b..6b776cde 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -85,6 +85,8 @@ import { type RespawnWiringDeps, } from './respawn-event-wiring.js'; +import { reconcileUpdateOnBoot } from './self-update.js'; + // Load version from package.json const require = createRequire(import.meta.url); const { version: APP_VERSION } = require('../../package.json'); @@ -1665,6 +1667,13 @@ export class WebServer extends EventEmitter { lifecycleLog.log({ event: 'server_started', sessionId: '*' }); await lifecycleLog.trimIfNeeded(); + // If a self-update restarted us into this process, finalize its status file + // (flip the persisted "restarting" marker → completed/failed based on the + // version we actually booted). No-op on a normal boot. See web/self-update.ts. + if (!this.testMode) { + reconcileUpdateOnBoot(); + } + // Restore mux sessions BEFORE accepting connections // This prevents race conditions where clients connect before state is ready // CRITICAL: Skip in test mode to prevent tests from picking up user sessions diff --git a/test/self-update.test.ts b/test/self-update.test.ts new file mode 100644 index 00000000..16d2bf40 --- /dev/null +++ b/test/self-update.test.ts @@ -0,0 +1,156 @@ +/** + * @fileoverview Unit tests for the self-updater's pure logic: release-tag/semver + * parsing, "update available" computation, and the boot-time reconcile state + * machine. No IO, no tmux, no port — safe to run individually. + * + * npm test -- test/self-update.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + parseVersionFromTag, + compareVersions, + isNewerStableVersion, + pickLatestStableTag, + isValidReleaseTag, + parseGitHubRepo, + reconcileStatusDecision, +} from '../src/web/self-update.js'; +import type { UpdateStatus } from '../src/types/update.js'; + +describe('parseVersionFromTag', () => { + it('parses the codeman@ / aicodeman@ / v / bare forms', () => { + expect(parseVersionFromTag('codeman@0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3, prerelease: '' }); + expect(parseVersionFromTag('aicodeman@1.2.3')).toMatchObject({ major: 1, minor: 2, patch: 3 }); + expect(parseVersionFromTag('v0.10.0')).toMatchObject({ major: 0, minor: 10, patch: 0 }); + expect(parseVersionFromTag('0.9.3')).toMatchObject({ major: 0, minor: 9, patch: 3 }); + }); + + it('captures a prerelease suffix', () => { + expect(parseVersionFromTag('codeman@0.9.3-rc1')).toMatchObject({ patch: 3, prerelease: 'rc1' }); + }); + + it('returns null when there is no X.Y.Z', () => { + expect(parseVersionFromTag('codeman@latest')).toBeNull(); + expect(parseVersionFromTag('garbage')).toBeNull(); + }); +}); + +describe('compareVersions', () => { + const v = (s: string) => parseVersionFromTag(s)!; + it('orders by major/minor/patch', () => { + expect(compareVersions(v('0.10.0'), v('0.9.3'))).toBeGreaterThan(0); + expect(compareVersions(v('0.9.3'), v('0.10.0'))).toBeLessThan(0); + expect(compareVersions(v('1.0.0'), v('0.99.99'))).toBeGreaterThan(0); + expect(compareVersions(v('0.9.3'), v('0.9.3'))).toBe(0); + }); + + it('ranks a release above a prerelease of the same core', () => { + expect(compareVersions(v('0.9.3'), v('0.9.3-rc1'))).toBeGreaterThan(0); + expect(compareVersions(v('0.9.3-rc1'), v('0.9.3'))).toBeLessThan(0); + }); +}); + +describe('isNewerStableVersion', () => { + it('true only for a strictly newer stable release', () => { + expect(isNewerStableVersion('0.9.3', '0.9.4')).toBe(true); + expect(isNewerStableVersion('0.9.3', '0.10.0')).toBe(true); + }); + it('false for same/older', () => { + expect(isNewerStableVersion('0.9.3', '0.9.3')).toBe(false); + expect(isNewerStableVersion('0.9.4', '0.9.3')).toBe(false); + }); + it('never offers a prerelease as an update', () => { + expect(isNewerStableVersion('0.9.3', '0.9.4-rc1')).toBe(false); + }); + it('false on unparseable input', () => { + expect(isNewerStableVersion('0.9.3', 'nope')).toBe(false); + }); +}); + +describe('pickLatestStableTag', () => { + it('picks the highest stable tag from ls-remote-style refs', () => { + const refs = [ + 'deadbeef\trefs/tags/codeman@0.8.2', + 'cafef00d\trefs/tags/codeman@0.9.3', + 'abc123\trefs/tags/codeman@0.10.0', + 'abc123\trefs/tags/codeman@0.10.0^{}', // dereferenced dup + ]; + expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.10.0', version: '0.10.0' }); + }); + + it('skips prereleases and unrecognized tags', () => { + const refs = ['x\trefs/tags/codeman@0.9.3', 'y\trefs/tags/codeman@0.9.4-rc1', 'z\trefs/tags/some-random-tag']; + expect(pickLatestStableTag(refs)).toEqual({ tag: 'codeman@0.9.3', version: '0.9.3' }); + }); + + it('returns null when nothing matches', () => { + expect(pickLatestStableTag([])).toBeNull(); + expect(pickLatestStableTag(['refs/tags/nightly', 'refs/heads/master'])).toBeNull(); + }); +}); + +describe('isValidReleaseTag', () => { + it('accepts only codeman@/aicodeman@ X.Y.Z (shell-injection guard)', () => { + expect(isValidReleaseTag('codeman@0.9.4')).toBe(true); + expect(isValidReleaseTag('aicodeman@1.0.0')).toBe(true); + expect(isValidReleaseTag('v0.9.4')).toBe(false); + expect(isValidReleaseTag('codeman@0.9.4; rm -rf /')).toBe(false); + expect(isValidReleaseTag('codeman@latest')).toBe(false); + }); +}); + +describe('parseGitHubRepo', () => { + it('handles SSH and HTTPS remotes', () => { + expect(parseGitHubRepo('git@github.com:Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' }); + expect(parseGitHubRepo('https://github.com/Ark0N/Codeman.git')).toEqual({ owner: 'Ark0N', repo: 'Codeman' }); + expect(parseGitHubRepo('https://github.com/Ark0N/Codeman')).toEqual({ owner: 'Ark0N', repo: 'Codeman' }); + }); + it('returns null for non-GitHub remotes', () => { + expect(parseGitHubRepo('https://gitlab.com/x/y.git')).toBeNull(); + }); +}); + +describe('reconcileStatusDecision (boot handoff state machine)', () => { + const NOW = 1_000_000_000_000; + const base = (over: Partial): UpdateStatus => ({ + updateId: 'u1', + phase: 'restarting', + message: '', + fromVersion: '0.9.3', + toVersion: '0.9.4', + startedAt: NOW - 5_000, + updatedAt: NOW - 5_000, + ...over, + }); + + it('no status / terminal status → untouched', () => { + expect(reconcileStatusDecision(null, '0.9.4', NOW)).toBeNull(); + expect(reconcileStatusDecision(base({ phase: 'completed' }), '0.9.4', NOW)).toBeNull(); + expect(reconcileStatusDecision(base({ phase: 'failed' }), '0.9.4', NOW)).toBeNull(); + }); + + it('restarting + running version matches target → completed', () => { + const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.4', NOW); + expect(out?.phase).toBe('completed'); + expect(out?.updatedAt).toBe(NOW); + }); + + it('restarting + version unchanged → failed', () => { + const out = reconcileStatusDecision(base({ phase: 'restarting' }), '0.9.3', NOW); + expect(out?.phase).toBe('failed'); + expect(out?.error).toContain('0.9.4'); + }); + + it('a fresh non-restart in-flight phase is left for the live updater', () => { + expect(reconcileStatusDecision(base({ phase: 'building' }), '0.9.3', NOW)).toBeNull(); + expect(reconcileStatusDecision(base({ phase: 'installing' }), '0.9.3', NOW)).toBeNull(); + }); + + it('a stale (abandoned) in-flight phase is failed by the backstop', () => { + const stale = base({ phase: 'building', startedAt: NOW - 20 * 60 * 1000 }); + const out = reconcileStatusDecision(stale, '0.9.3', NOW); + expect(out?.phase).toBe('failed'); + expect(out?.error).toContain('building'); + }); +});