mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +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`.
|
**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`).
|
**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=""
|
REPO=""
|
||||||
TAG=""
|
TAG=""
|
||||||
SUPERVISOR="none"
|
SUPERVISOR="none"
|
||||||
|
SERVER_PID=""
|
||||||
STATUS_FILE=""
|
STATUS_FILE=""
|
||||||
UPDATE_ID=""
|
UPDATE_ID=""
|
||||||
FROM_VERSION=""
|
FROM_VERSION=""
|
||||||
@@ -50,6 +51,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--node) NODE="$2"; shift 2 ;;
|
--node) NODE="$2"; shift 2 ;;
|
||||||
--log) LOG="$2"; shift 2 ;;
|
--log) LOG="$2"; shift 2 ;;
|
||||||
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
--prev-sha) PREV_SHA="$2"; shift 2 ;;
|
||||||
|
--server-pid) SERVER_PID="$2"; shift 2 ;;
|
||||||
--stash) DO_STASH=1; shift ;;
|
--stash) DO_STASH=1; shift ;;
|
||||||
*) shift ;;
|
*) shift ;;
|
||||||
esac
|
esac
|
||||||
@@ -198,6 +200,19 @@ case "$SUPERVISOR" in
|
|||||||
|| fail "Build succeeded but launchd restart failed" "launchctl"
|
|| 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 &"
|
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
|
||||||
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
|
||||||
|
|||||||
+6
-2
@@ -13,8 +13,12 @@
|
|||||||
* @module types/update
|
* @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. */
|
/** How Codeman was installed — only `git` installs can self-update in place. */
|
||||||
export type InstallKind = 'git' | 'npm' | 'unknown';
|
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.
|
* persist — or null to leave it untouched.
|
||||||
*
|
*
|
||||||
* Rules (see plan "Hardening"):
|
* 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
|
* - Only the `restarting` marker (written right before the updater triggers our
|
||||||
* restart) flips to completed/failed by comparing running version vs. target.
|
* restart) flips to completed/failed by comparing running version vs. target.
|
||||||
* - Other in-flight phases are owned by the still-running updater scope — leave
|
* - Other in-flight phases are owned by the still-running updater scope — leave
|
||||||
@@ -174,6 +176,17 @@ export function reconcileStatusDecision(
|
|||||||
now: number
|
now: number
|
||||||
): UpdateStatus | null {
|
): UpdateStatus | null {
|
||||||
if (!status) return 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 (!IN_FLIGHT_PHASES.has(status.phase)) return null;
|
||||||
|
|
||||||
if (status.phase === 'restarting') {
|
if (status.phase === 'restarting') {
|
||||||
@@ -275,6 +288,16 @@ function detectInstallKind(dir: string): InstallKind {
|
|||||||
export function detectSupervisor(): SupervisorKind {
|
export function detectSupervisor(): SupervisorKind {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
|
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';
|
return 'none';
|
||||||
}
|
}
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
@@ -535,6 +558,10 @@ export async function startUpdate(): Promise<StartUpdateResult> {
|
|||||||
process.execPath,
|
process.execPath,
|
||||||
'--log',
|
'--log',
|
||||||
logFile,
|
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 (prevSha) args.push('--prev-sha', prevSha);
|
||||||
if (info.dirty) args.push('--stash');
|
if (info.dirty) args.push('--stash');
|
||||||
|
|||||||
@@ -153,4 +153,17 @@ describe('reconcileStatusDecision (boot handoff state machine)', () => {
|
|||||||
expect(out?.phase).toBe('failed');
|
expect(out?.phase).toBe('failed');
|
||||||
expect(out?.error).toContain('building');
|
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