mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(self-update): launchd-daemon supervisor — rootless restart on headless Macs
A KeepAlive system-level LaunchDaemon (the right setup for headless Macs, where no GUI login means LaunchAgents never start) is now detected as supervisor 'launchd-daemon': the updater kills the server PID (passed via --server-pid) and launchd respawns it on the new dist/ — no root needed. Detection requires the daemon plist to be bootstrapped AND KeepAlive=true. Also: on boot, a 'completed-needs-manual-restart' status auto-completes when the running version matches the staged target, so the stale 'restart Codeman to apply' instruction no longer lingers in the UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <release tag> && 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 <release tag> && 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`).
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
+6
-2
@@ -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';
|
||||
|
||||
+28
-1
@@ -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<StartUpdateResult> {
|
||||
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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user