@staix/agent-hub 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { ControlClient, PROTOCOL, readControl } from "../hub/control-client.ts";
5
+ import { inspectProject } from "../hub/lifecycle.ts";
6
+ import type { Project } from "../hub/registry.ts";
7
+ import { hubHome } from "../hub/project.ts";
8
+ import { packageDigest, registryRelease, runCommand, stageRelease, verifyPackage, type RunCommand } from "./recovery-package.ts";
9
+ import { inspectTerminals, closeTerminal, createTerminal, waitForIdle, shellQuote, type SessionRef, type TerminalBinding, type TerminalRecoveryOptions } from "./terminal-recovery.ts";
10
+ import { planFingerprint, registeredProjects, type Inspection, type PlannedProject, type ProjectProgress, type RecoveryDriver, type RecoveryOperation, type UpgradePlan } from "./upgrade.ts";
11
+ import { refreshManager } from "../hub/manager.ts";
12
+
13
+ export const PACKAGE_ROOT = resolve(import.meta.dir, "../..");
14
+ const orcaExecutable = () => process.env.ORCA_CLI_COMMAND || (process.env.ORCA_DEV_REPO_ROOT ? "orca-dev" : process.platform === "linux" && !process.env.ORCA_TERMINAL_HANDLE ? "orca-ide" : "orca");
15
+
16
+ function terminalOptions(run: RunCommand = runCommand): TerminalRecoveryOptions {
17
+ return { orcaExecutable: orcaExecutable(), runner: async (args) => {
18
+ const result = await run([orcaExecutable(), ...args], { timeoutMs: 610_000 });
19
+ return { status: result.code, stdout: result.stdout, stderr: result.stderr };
20
+ } };
21
+ }
22
+
23
+ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, binding: TerminalBinding): string[] {
24
+ if (binding.peer === "codex") return [process.execPath, entrypoint, "--project", projectRoot, "codex", "resume", binding.sessionId];
25
+ if (binding.peer === "claude") return [process.execPath, entrypoint, "--project", projectRoot, "claude", "--resume", binding.sessionId];
26
+ return [process.execPath, entrypoint, "--project", projectRoot, "pi", "--mode", "tui",
27
+ ...(binding.backend ? ["--backend", binding.backend] : []), ...(binding.model ? ["--model", binding.model] : []),
28
+ ...(binding.sessionFile ? ["--session-file", binding.sessionFile] : ["--session-id", binding.sessionId])];
29
+ }
30
+
31
+ async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
32
+ const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, 30_000, protocol);
33
+ try {
34
+ const reply = await client.request(message, 30_000);
35
+ if (reply.ok === false) throw new Error(reply.error ?? "recovery control request failed");
36
+ return reply;
37
+ } finally { client.close(); }
38
+ }
39
+
40
+ export async function inspectRecovery(project: Project): Promise<Inspection> {
41
+ const base = await inspectProject(project);
42
+ const control = readControl(project.stateDir);
43
+ const sourceProtocol = control?.protocol;
44
+ const legacySupported = sourceProtocol === 8;
45
+ if (base.state !== "running" && !legacySupported) return {
46
+ state: base.state, peers: [], blockers: base.state === "stopped" ? [] : [base.state === "incompatible" ? "manual-bootstrap-required: source lacks the recovery contract; use its matching CLI" : base.error ?? base.state],
47
+ ...(control?.instanceId ? { instanceId: control.instanceId } : {}), ...(control?.protocol ? { protocol: control.protocol } : {}),
48
+ };
49
+ let status = base.status;
50
+ if (!status && legacySupported) {
51
+ const readback = await rpc(project, { t: "status" }, sourceProtocol);
52
+ status = readback.status;
53
+ }
54
+ if (!status || !control) return { state: "unavailable", peers: [], blockers: ["authenticated source status unavailable"] };
55
+ let response: any;
56
+ try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId }, sourceProtocol ?? PROTOCOL); }
57
+ catch {
58
+ // A stop can complete between authenticated status and the second read. Never infer stopped
59
+ // from a connection failure: the coordinator will inspect the ownership manifest again.
60
+ return { state: "unavailable", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
61
+ peers: [], blockers: ["runtime changed during recovery inspection"] };
62
+ }
63
+ const peers = Object.values(response.recovery?.peers ?? {}) as any[];
64
+ return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
65
+ recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
66
+ ...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
67
+ ...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
68
+ ...(peer.launch ? { args: Object.fromEntries(Object.entries(peer.launch).filter(([k, v]) => ["model", "route", "sessionFile", "mode", "backend"].includes(k) && typeof v === "string")) as Record<string, string> } : {}) })), blockers: [] };
69
+ }
70
+
71
+ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand): Promise<UpgradePlan> {
72
+ const projects = registeredProjects();
73
+ const sourceDigest = packageDigest(PACKAGE_ROOT);
74
+ const currentVersion = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
75
+ const release = kind === "upgrade" ? await registryRelease(version, run) : undefined;
76
+ const body: Omit<UpgradePlan, "fingerprint"> = { schema: 1, kind, version: kind === "restart" ? currentVersion : version,
77
+ sourceRoot: PACKAGE_ROOT, sourceDigest, ...(release ? { integrity: release.integrity } : {}), projects: [], blockers: [] };
78
+ if (kind === "upgrade") {
79
+ try {
80
+ if ((await run(["claude", "--version"], { timeoutMs: 10_000 })).code !== 0) body.blockers.push("shared Claude plugin installer is unavailable");
81
+ } catch { body.blockers.push("shared Claude plugin installer is unavailable"); }
82
+ }
83
+ const managerFile = join(hubHome(), "manager/status.json");
84
+ if (existsSync(managerFile)) {
85
+ try {
86
+ const manager = JSON.parse(readFileSync(managerFile, "utf8"));
87
+ if (![8, PROTOCOL].includes(manager.protocol)) body.blockers.push("manager requires manual bootstrap with its matching CLI before protocol-9 recovery");
88
+ } catch { body.blockers.push("manager ownership manifest is unreadable"); }
89
+ }
90
+ for (const project of projects) {
91
+ if (kind === "restart" && project.root !== selectedRoot) continue;
92
+ let source: Inspection;
93
+ try { source = await inspectRecovery(project); }
94
+ catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
95
+ if (source.state === "stopped" || source.state === "missing") continue;
96
+ const blockers = [...source.blockers];
97
+ if (![8, PROTOCOL].includes(source.protocol ?? 0) || source.state !== "running") blockers.push("manual-bootstrap-required: an authenticated recovery-capable source is required");
98
+ if (source.recovery?.operationId && source.recovery.phase !== "released") blockers.push(`existing recovery operation ${source.recovery.operationId} must be resolved first`);
99
+ const sessions: { codex?: string; claude?: string; pi?: SessionRef } = {};
100
+ for (const peer of source.peers) {
101
+ if (peer.state === "offline") continue;
102
+ if (peer.id === "codex" || peer.id === "claude") {
103
+ const session = peer.id === "codex" ? peer.threadId : peer.sessionId;
104
+ if (!session) blockers.push(`${peer.id}: original conversation ID is unknown; manual-required`);
105
+ else sessions[peer.id] = session;
106
+ } else if (peer.id === "pi" && peer.args?.mode === "tui") {
107
+ if (!peer.sessionId) blockers.push("pi: original session ID is unknown; manual-required");
108
+ else sessions.pi = { sessionId: peer.sessionId, ...(peer.sessionFile ? { sessionFile: peer.sessionFile } : {}), ...(peer.args.backend ? { backend: peer.args.backend } : {}), ...(peer.args.model ? { model: peer.args.model } : {}) };
109
+ } else if (peer.id !== "kimi" && peer.id !== "local" && peer.id !== "pi") blockers.push(`${peer.id}: no automatic recovery adapter`);
110
+ }
111
+ const terminals = await inspectTerminals(project.root, sessions, {
112
+ ...terminalOptions(run), stateDir: project.stateDir, instanceId: source.instanceId,
113
+ });
114
+ blockers.push(...terminals.blockers.map((b) => b.message));
115
+ body.projects.push({ project, source, terminals: terminals.bindings, blockers });
116
+ }
117
+ if (!body.projects.length) body.blockers.push("no running registered projects in scope");
118
+ return { ...body, fingerprint: planFingerprint(body) };
119
+ }
120
+
121
+ /** Freeze the coordinator/source package before its mutable global installation is replaced. */
122
+ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
123
+ if (packageDigest(plan.sourceRoot) !== plan.sourceDigest) throw new Error("source package changed since planning");
124
+ const base = join(home, "releases");
125
+ const dest = join(base, `source-${plan.sourceDigest}`);
126
+ if (existsSync(dest)) {
127
+ if (packageDigest(dest) !== plan.sourceDigest) throw new Error("preserved source package changed");
128
+ return dest;
129
+ }
130
+ mkdirSync(base, { recursive: true, mode: 0o700 });
131
+ const temporary = join(base, `.source-${randomUUID()}`);
132
+ mkdirSync(temporary, { mode: 0o700 });
133
+ try {
134
+ for (const name of ["src", "plugins", "templates", ".claude-plugin", "package.json", "README.md", "LICENSE", "CHANGELOG.md"]) {
135
+ cpSync(join(plan.sourceRoot, name), join(temporary, name), { recursive: true, dereference: false });
136
+ }
137
+ if (packageDigest(temporary) !== plan.sourceDigest) throw new Error("source changed while being preserved");
138
+ renameSync(temporary, dest);
139
+ } finally { rmSync(temporary, { recursive: true, force: true }); }
140
+ return dest;
141
+ }
142
+
143
+ export function makeRecoveryDriver(run: RunCommand = runCommand): RecoveryDriver {
144
+ const now = () => Date.now();
145
+ const sleep = (ms: number) => Bun.sleep(ms);
146
+ const env = (op: RecoveryOperation, project?: Project): NodeJS.ProcessEnv => {
147
+ const value = { ...process.env };
148
+ // Do not carry one project's gateway, auth overrides or unattended mode into another.
149
+ for (const key of Object.keys(value)) if (key.startsWith("AGENTHUB_") || key.startsWith("OMNIROUTE_") || key.startsWith("CF_ACCESS_") ||
150
+ (project && /(?:_API_KEY|_ACCESS_TOKEN|_SECRET|_TOKEN)$/.test(key))) delete value[key];
151
+ value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
152
+ value.AGENTHUB_UNATTENDED = "0";
153
+ if (project) {
154
+ value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
155
+ delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
156
+ const bindings = op.plan.projects.find((p) => p.project.id === project.id)?.terminals as TerminalBinding[] | undefined;
157
+ for (const binding of bindings ?? []) {
158
+ const key = binding.peer === "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR";
159
+ if (binding.launch.env[key]) value[key] = binding.launch.env[key];
160
+ }
161
+ }
162
+ return value;
163
+ };
164
+ const command = async (op: RecoveryOperation, args: string[], project?: Project) => {
165
+ const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...args],
166
+ { env: env(op, project), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
167
+ if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
168
+ return result;
169
+ };
170
+ const control = (project: Project, op: string, id: string, instance: string) => {
171
+ const sourceProtocol = readControl(project.stateDir)?.protocol;
172
+ const protocol = sourceProtocol === 8 ? 8 : PROTOCOL;
173
+ return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance }, protocol).then(() => {});
174
+ };
175
+ const revalidateTerminal = async (planned: PlannedProject, progress: ProjectProgress, saved: TerminalBinding, exact: boolean): Promise<TerminalBinding> => {
176
+ const options = { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId };
177
+ const found = await inspectTerminals(planned.project.root, { [saved.peer]: saved.sessionId }, options);
178
+ const current = found.byPeer[saved.peer];
179
+ if (found.manualRequired || !current || (exact && (current.handle !== saved.handle || current.incarnationId !== saved.incarnationId || current.worktreeId !== saved.worktreeId || current.projectRoot !== saved.projectRoot))) {
180
+ throw new Error(`${saved.peer}: saved terminal identity changed or is not verified; manual-required`);
181
+ }
182
+ const idle = await waitForIdle(exact ? saved : current, 120_000, options);
183
+ if (!idle.satisfied) throw new Error(`${saved.peer}: terminal is not idle; manual-required`);
184
+ const observed = await inspectRecovery(planned.project);
185
+ if (observed.instanceId !== progress.instanceId) throw new Error(`${saved.peer}: daemon instance changed during terminal revalidation`);
186
+ const peer = observed.peers.find((item) => item.id === saved.peer);
187
+ const session = saved.peer === "codex" ? peer?.threadId : peer?.sessionId;
188
+ if (session !== saved.sessionId) throw new Error(`${saved.peer}: daemon session changed during terminal revalidation; manual-required`);
189
+ return current;
190
+ };
191
+ return {
192
+ now, sleep, inspect: inspectRecovery,
193
+ stage: async (op) => {
194
+ if (packageDigest(op.sourceRoot) !== op.plan.sourceDigest) throw new Error("preserved source changed");
195
+ const target = op.plan.kind === "restart" ? { root: op.sourceRoot, digest: verifyPackage(op.sourceRoot, op.plan.version) }
196
+ : await stageRelease(op.plan.version, op.plan.integrity!, run);
197
+ const protocol = await run([process.execPath, "-e", `import { PROTOCOL } from ${JSON.stringify(join(target.root, "src/hub/control-client.ts"))}; console.log(PROTOCOL)`]);
198
+ if (protocol.code !== 0 || Number(protocol.stdout.trim()) !== PROTOCOL) throw new Error("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
199
+ return target;
200
+ },
201
+ prepare: (p, id, instance) => control(p, "prepare", id, instance),
202
+ abort: (p, id, instance) => control(p, "abort", id, instance),
203
+ commit: (p, id, instance) => control(p, "commit", id, instance),
204
+ release: (p, id, instance) => control(p, "release", id, instance),
205
+ closeTerminals: async (planned, progress, op, save) => {
206
+ for (const binding of planned.terminals as TerminalBinding[]) {
207
+ const key = `closed:${binding.peer}`;
208
+ if (progress.terminals[key] === true) continue;
209
+ if (progress.terminals[key] === "pending") {
210
+ const result = await run([orcaExecutable(), "terminal", "list", "--json"]);
211
+ const inventory = JSON.parse(result.stdout);
212
+ const rows = inventory.result?.terminals;
213
+ if (result.code !== 0 || inventory.ok !== true || !Array.isArray(rows) || inventory.result?.truncated ||
214
+ rows.some((t: any) => t.handle === binding.handle || t.incarnationId === binding.incarnationId)) {
215
+ throw new Error(`${binding.peer}: terminal close outcome needs manual reconciliation`);
216
+ }
217
+ progress.terminals[key] = true; save(); continue;
218
+ }
219
+ const idle = await waitForIdle(binding, 600_000, terminalOptions(run));
220
+ if (!idle.satisfied) throw new Error(`${binding.peer}: terminal is not verified idle; source retained`);
221
+ const source = await inspectRecovery(planned.project);
222
+ if (source.instanceId !== planned.source.instanceId || source.recovery?.operationId !== op.id || !source.recovery.ready) {
223
+ throw new Error("source preparation expired or changed while waiting for the terminal; no terminal was closed");
224
+ }
225
+ // Refresh the same prepared lease immediately before the terminal effect. Preserve
226
+ // the original peer roster, including any terminal already closed in this operation.
227
+ await control(planned.project, "prepare", op.id, planned.source.instanceId!);
228
+ progress.terminals[key] = "pending"; save();
229
+ const result = await closeTerminal(binding, 0, terminalOptions(run));
230
+ if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
231
+ progress.terminals[key] = true; save();
232
+ }
233
+ },
234
+ start: async (p, op) => {
235
+ const checkpoint = JSON.parse(readFileSync(join(p.stateDir, "restart.json"), "utf8"));
236
+ if (checkpoint.projectId !== p.id || checkpoint.projectRoot !== p.root || checkpoint.operationId !== op.id) {
237
+ throw new Error("committed restart snapshot is missing or belongs to another operation");
238
+ }
239
+ await command(op, ["up"], p);
240
+ },
241
+ restore: async (planned, progress, op, group, save) => {
242
+ const live = await inspectRecovery(planned.project);
243
+ if (live.instanceId !== progress.instanceId) throw new Error("target daemon changed before peer restore");
244
+ if (group === "native") {
245
+ for (const peer of planned.source.peers.filter((p) => ["kimi", "local", "pi"].includes(p.id) && p.state !== "offline" && !(p.id === "pi" && p.args?.mode === "tui"))) {
246
+ const current = live.peers.find((p) => p.id === peer.id);
247
+ if (current && current.state !== "offline") continue;
248
+ const args = [peer.id];
249
+ for (const key of ["mode", "backend", "model", "route"]) if (typeof peer.args?.[key] === "string") args.push(`--${key}`, peer.args[key]!);
250
+ if (peer.id === "pi") {
251
+ const sessionFile = peer.sessionFile ?? peer.args?.sessionFile;
252
+ if (typeof sessionFile === "string") args.push("--session-file", sessionFile);
253
+ else if (peer.sessionId) args.push("--session-id", peer.sessionId);
254
+ }
255
+ await command(op, args, planned.project);
256
+ }
257
+ }
258
+ for (const original of planned.terminals as TerminalBinding[]) {
259
+ if ((original.peer === "claude") !== (group === "claude")) continue;
260
+ const key = `restored:${original.peer}`;
261
+ if (progress.terminals[key] && progress.terminals[key] !== "pending") {
262
+ progress.terminals[key] = await revalidateTerminal(planned, progress, progress.terminals[key] as TerminalBinding, true); save();
263
+ continue;
264
+ }
265
+ if (progress.terminals[key] === "pending") {
266
+ const observed = await inspectRecovery(planned.project);
267
+ const peer = observed.peers.find((p) => p.id === original.peer);
268
+ if ((original.peer === "codex" ? peer?.threadId : peer?.sessionId) !== original.sessionId) {
269
+ throw new Error(`${original.peer}: terminal creation outcome is uncertain; attach the original session manually, then resume`);
270
+ }
271
+ const existing = await revalidateTerminal(planned, progress, original, false);
272
+ progress.terminals[key] = existing; save(); continue;
273
+ }
274
+ const entrypoint = join(op.targetRoot!, "src/cli/main.js");
275
+ const argv = restoredTerminalArgv(entrypoint, planned.project.root, original);
276
+ const assignments = { ...original.launch.env, AGENTHUB_HOME: hubHome(), AGENTHUB_RECOVERY_OPERATION: op.id };
277
+ const launch = { ...original.launch, packageEntrypoint: entrypoint, argv,
278
+ command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
279
+ const binding = { ...original, launch, launchMetadata: launch };
280
+ progress.terminals[key] = "pending"; save();
281
+ const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId });
282
+ if (restored.manualRequired || !restored.newBinding) throw new Error(`${original.peer}: original session restoration needs manual verification`);
283
+ progress.terminals[key] = restored.newBinding; save();
284
+ }
285
+ },
286
+ installPlugin: async (op) => {
287
+ const configured = new Set<string>();
288
+ for (const planned of op.plan.projects) {
289
+ const binding = (planned.terminals as TerminalBinding[]).find((t) => t.peer === "claude");
290
+ if (!binding) continue;
291
+ const profile = binding.launch.env.CLAUDE_CONFIG_DIR ?? "default";
292
+ if (configured.has(profile)) continue;
293
+ await command(op, ["setup", "--yes"], planned.project);
294
+ configured.add(profile);
295
+ }
296
+ if (!configured.size) await command(op, ["setup", "--yes"]);
297
+ },
298
+ installGlobal: async (op) => {
299
+ const result = await run([process.execPath, "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts"], { env: env(op), timeoutMs: 180_000 });
300
+ if (result.code !== 0) throw new Error("global installation failed; project runtimes use the retained staged package");
301
+ const readback = await run(["ahub", "--version"], { env: env(op) });
302
+ if (readback.code !== 0 || readback.stdout.trim() !== op.plan.version) throw new Error("global CLI version was not verified");
303
+ },
304
+ refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
305
+ verify: async (planned, progress, op) => {
306
+ const live = await inspectRecovery(planned.project);
307
+ if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");
308
+ for (const old of planned.source.peers.filter((p) => p.state !== "offline")) {
309
+ const peer = live.peers.find((p) => p.id === old.id);
310
+ if (!peer || !["idle", "busy", "paused"].includes(peer.state)) throw new Error(`${old.id}: peer reattachment not verified`);
311
+ if (old.id === "codex" && peer.threadId !== old.threadId) throw new Error("Codex resumed a different conversation");
312
+ if (old.id === "claude" && peer.sessionId !== old.sessionId) throw new Error("Claude resumed a different conversation");
313
+ if (old.id === "pi" && (peer.sessionId !== old.sessionId || (old.sessionFile && peer.sessionFile !== old.sessionFile))) throw new Error("Pi resumed a different conversation");
314
+ }
315
+ for (const original of planned.terminals as TerminalBinding[]) {
316
+ const saved = progress.terminals[`restored:${original.peer}`];
317
+ if (!saved || saved === "pending") throw new Error(`${original.peer}: restored terminal outcome is not recorded; manual-required`);
318
+ await revalidateTerminal(planned, progress, saved as TerminalBinding, true);
319
+ }
320
+ const snapshot = await rpc(planned.project, { t: "ui_snapshot", after: 0 });
321
+ if (snapshot.ok === false) throw new Error("dashboard snapshot readback failed");
322
+ const recovery = await rpc(planned.project, { t: "recovery", op: "inspect", expectedInstanceId: progress.instanceId });
323
+ const integrity = recovery.recovery?.integrity;
324
+ if (!integrity?.expected || JSON.stringify(integrity.current) !== JSON.stringify(integrity.expected)) {
325
+ throw new Error("queue, manual pause, task board or budget preservation was not verified");
326
+ }
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,298 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import type { Project } from "../hub/registry.ts";
6
+ import { hubHome } from "../hub/project.ts";
7
+ import { acquireRecoveryLock, claimRunner, readOperation, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
8
+
9
+ export interface RecoveryPeer {
10
+ id: string;
11
+ state: string;
12
+ threadId?: string;
13
+ sessionId?: string;
14
+ sessionFile?: string;
15
+ args?: Record<string, string>;
16
+ }
17
+ export interface Inspection {
18
+ state: string;
19
+ instanceId?: string;
20
+ version?: string;
21
+ protocol?: number;
22
+ peers: RecoveryPeer[];
23
+ recovery?: { operationId?: string; phase?: string; ready?: boolean };
24
+ blockers: string[];
25
+ }
26
+ export interface PlannedProject {
27
+ project: Project;
28
+ source: Inspection;
29
+ terminals: unknown[];
30
+ blockers: string[];
31
+ }
32
+ export interface UpgradePlan {
33
+ schema: 1;
34
+ kind: "restart" | "upgrade";
35
+ version: string;
36
+ integrity?: string;
37
+ sourceRoot: string;
38
+ sourceDigest: string;
39
+ projects: PlannedProject[];
40
+ fingerprint: string;
41
+ blockers: string[];
42
+ }
43
+ export interface ProjectProgress {
44
+ id: string;
45
+ phase: "pending" | "prepared" | "stopped" | "started" | "peers-restored" | "verified";
46
+ instanceId?: string;
47
+ terminals: Record<string, unknown>;
48
+ releaseRequested?: boolean;
49
+ }
50
+ export interface RecoveryOperation {
51
+ schema: 1;
52
+ id: string;
53
+ plan: UpgradePlan;
54
+ createdAt: number;
55
+ updatedAt: number;
56
+ phase: "pending" | "running" | "blocked" | "completed" | "cancelled";
57
+ step: string;
58
+ sourceRoot: string;
59
+ targetRoot?: string;
60
+ targetDigest?: string;
61
+ projects: ProjectProgress[];
62
+ pluginInstalled?: boolean;
63
+ globalInstalled?: boolean;
64
+ error?: string;
65
+ }
66
+
67
+ /** Registry reads for planning must not create a registry or run migrations. */
68
+ export function registeredProjects(home = hubHome()): Project[] {
69
+ const path = join(home, "registry.db");
70
+ if (!existsSync(path)) return [];
71
+ const db = new Database(path, { readonly: true });
72
+ try {
73
+ return db.query("SELECT id, root, state_dir as stateDir, base_port as basePort, instance_id as instanceId, pid FROM projects ORDER BY root").all() as Project[];
74
+ } finally { db.close(); }
75
+ }
76
+
77
+ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string {
78
+ const stable = { ...plan, projects: plan.projects.map((p) => ({ ...p, source: { ...p.source, recovery: undefined,
79
+ peers: p.source.peers.map(({ state, ...peer }) => ({ ...peer, active: state !== "offline" })) } })) };
80
+ return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
81
+ }
82
+
83
+ export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome()): RecoveryOperation {
84
+ if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
85
+ if (!plan.projects.length) throw new Error("no running projects to recover");
86
+ const { fingerprint, ...body } = plan;
87
+ if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
88
+ const id = randomUUID();
89
+ const op: RecoveryOperation = {
90
+ schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
91
+ projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
92
+ };
93
+ writeOperation(id, op, home);
94
+ acquireRecoveryLock(id, home);
95
+ return op;
96
+ }
97
+
98
+ function validateReceipt(id: string, op: RecoveryOperation): void {
99
+ if (op.schema !== 1 || op.id !== id) throw new Error("unsupported operation receipt");
100
+ const { fingerprint, ...reviewed } = op.plan;
101
+ if (fingerprint !== planFingerprint(reviewed) || op.projects.length !== op.plan.projects.length ||
102
+ op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
103
+ }
104
+
105
+ export interface RecoveryDriver {
106
+ stage(op: RecoveryOperation): Promise<{ root: string; digest: string }>;
107
+ inspect(project: Project): Promise<Inspection>;
108
+ prepare(project: Project, operation: string, instance: string): Promise<void>;
109
+ abort(project: Project, operation: string, instance: string): Promise<void>;
110
+ commit(project: Project, operation: string, instance: string): Promise<void>;
111
+ closeTerminals(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, save: () => void): Promise<void>;
112
+ start(project: Project, op: RecoveryOperation): Promise<void>;
113
+ restore(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation, group: "native" | "claude", save: () => void): Promise<void>;
114
+ installPlugin(op: RecoveryOperation): Promise<void>;
115
+ installGlobal(op: RecoveryOperation): Promise<void>;
116
+ refreshManager?(op: RecoveryOperation): Promise<void>;
117
+ release(project: Project, operation: string, instance: string): Promise<void>;
118
+ verify(planned: PlannedProject, progress: ProjectProgress, op: RecoveryOperation): Promise<void>;
119
+ sleep(ms: number): Promise<void>;
120
+ now(): number;
121
+ }
122
+
123
+ /** Resumable state machine. Driver actions are preceded by receipts and followed by live readback. */
124
+ export async function runRecovery(id: string, driver: RecoveryDriver, home = hubHome(), idleTimeoutMs = 600_000): Promise<RecoveryOperation> {
125
+ // Read once to validate the selector, then claim the runner before trusting any
126
+ // mutable phase. A concurrent runner may have completed the operation meanwhile.
127
+ let op = readOperation<RecoveryOperation>(id, home);
128
+ validateReceipt(id, op);
129
+ acquireRecoveryLock(id, home);
130
+ const releaseRunner = claimRunner(id, home);
131
+ try {
132
+ op = readOperation<RecoveryOperation>(id, home);
133
+ validateReceipt(id, op);
134
+ if (op.phase === "completed" || op.phase === "cancelled") {
135
+ if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
136
+ releaseRunner();
137
+ return op;
138
+ }
139
+ } catch (error) { releaseRunner(); throw error; }
140
+ const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
141
+ const step = (value: string) => { op.step = value; save(); };
142
+ const identity = (observed: Inspection, planned: PlannedProject, progress: ProjectProgress) => {
143
+ if (observed.state !== "running") throw new Error(`${planned.project.id}: runtime is ${observed.state}`);
144
+ if (progress.instanceId && observed.instanceId !== progress.instanceId) throw new Error("daemon instance changed; refusing to act on its replacement");
145
+ if (observed.recovery?.operationId !== id) throw new Error("daemon is not owned by this recovery operation");
146
+ if (observed.version !== op.plan.version) throw new Error("target daemon version mismatch");
147
+ };
148
+ const sourceRoster = (live: Inspection, planned: PlannedProject) => {
149
+ const expected = planned.source.peers.filter((p) => p.state !== "offline");
150
+ const active = live.peers.filter((p) => p.state !== "offline");
151
+ if (active.length !== expected.length || expected.some((peer) => {
152
+ const current = active.find((p) => p.id === peer.id);
153
+ return !current || current.threadId !== peer.threadId || current.sessionId !== peer.sessionId;
154
+ })) throw new Error("source conversation or active peer membership changed; make a new plan");
155
+ };
156
+ try {
157
+ op.phase = "running"; delete op.error; save();
158
+ step("stage");
159
+ const target = await driver.stage(op);
160
+ if (op.targetDigest && (target.root !== op.targetRoot || target.digest !== op.targetDigest)) throw new Error("staged target changed since operation started");
161
+ op.targetRoot = target.root; op.targetDigest = target.digest; save();
162
+
163
+ // Check every untouched source before stopping even the first project.
164
+ for (let i = 0; i < op.projects.length; i++) {
165
+ const progress = op.projects[i]!, planned = op.plan.projects[i]!;
166
+ if (progress.phase !== "pending") continue;
167
+ const live = await driver.inspect(planned.project);
168
+ if (live.state !== "running" || live.instanceId !== planned.source.instanceId || live.version !== planned.source.version) {
169
+ throw new Error(`${planned.project.id}: source runtime changed; make a new plan`);
170
+ }
171
+ sourceRoster(live, planned);
172
+ }
173
+ for (let i = 0; i < op.projects.length; i++) {
174
+ const progress = op.projects[i]!, planned = op.plan.projects[i]!, project = planned.project;
175
+ if (progress.phase === "verified" || progress.phase === "peers-restored") continue;
176
+ if (progress.phase === "pending") {
177
+ step(`prepare:${project.id}`);
178
+ await driver.prepare(project, id, planned.source.instanceId!);
179
+ const deadline = driver.now() + idleTimeoutMs;
180
+ for (;;) {
181
+ const live = await driver.inspect(project);
182
+ if (live.instanceId !== planned.source.instanceId) throw new Error("source daemon changed during preparation");
183
+ if (live.recovery?.operationId !== id) throw new Error("preparation expired or belongs to another operation");
184
+ if (live.recovery.ready) { sourceRoster(live, planned); break; }
185
+ if (driver.now() >= deadline) {
186
+ await driver.abort(project, id, planned.source.instanceId!);
187
+ throw new Error(`${project.id}: active turns or approvals did not finish; source runtime left running`);
188
+ }
189
+ await driver.sleep(250);
190
+ }
191
+ progress.phase = "prepared"; save();
192
+ }
193
+ if (progress.phase === "prepared") {
194
+ step(`commit:${project.id}`);
195
+ let live = await driver.inspect(project);
196
+ if (live.state === "running" && live.instanceId === planned.source.instanceId) {
197
+ if (live.recovery?.operationId !== id || !live.recovery.ready) throw new Error("source is no longer prepared");
198
+ await driver.closeTerminals(planned, progress, op, save);
199
+ await driver.commit(project, id, planned.source.instanceId!);
200
+ const deadline = driver.now() + 30_000;
201
+ do {
202
+ live = await driver.inspect(project);
203
+ if (live.state === "stopped") break;
204
+ if (live.instanceId && live.instanceId !== planned.source.instanceId) throw new Error("another daemon started during shutdown");
205
+ await driver.sleep(100);
206
+ } while (driver.now() < deadline);
207
+ }
208
+ if (live.state !== "stopped") throw new Error("old shutdown is not verified; not starting a second daemon");
209
+ progress.phase = "stopped"; save();
210
+ }
211
+ if (progress.phase === "stopped") {
212
+ step(`start:${project.id}`);
213
+ let live = await driver.inspect(project);
214
+ if (live.state === "stopped") {
215
+ await driver.start(project, op);
216
+ live = await driver.inspect(project);
217
+ }
218
+ identity(live, planned, progress);
219
+ progress.instanceId = live.instanceId; progress.phase = "started"; save();
220
+ }
221
+ if (progress.phase === "started") {
222
+ identity(await driver.inspect(project), planned, progress);
223
+ step(`restore:${project.id}`);
224
+ await driver.restore(planned, progress, op, "native", save);
225
+ progress.phase = "peers-restored"; save();
226
+ }
227
+ }
228
+ // Claude is deliberately a shared final phase: no project-level plugin isolation is claimed.
229
+ if (op.plan.kind === "upgrade" && !op.pluginInstalled) {
230
+ step("install-plugin"); await driver.installPlugin(op); op.pluginInstalled = true; save();
231
+ }
232
+ for (let i = 0; i < op.projects.length; i++) {
233
+ const progress = op.projects[i]!, planned = op.plan.projects[i]!;
234
+ if (progress.phase === "verified") continue;
235
+ const observed = await driver.inspect(planned.project);
236
+ identity(observed, planned, progress);
237
+ if (progress.releaseRequested && observed.recovery?.phase === "released") {
238
+ // Queues and tasks may legitimately change after release. Never replay or compare
239
+ // that live work to the old snapshot when only the release reply was lost.
240
+ progress.phase = "verified"; save(); continue;
241
+ }
242
+ step(`restore-claude:${planned.project.id}`);
243
+ await driver.restore(planned, progress, op, "claude", save);
244
+ await driver.verify(planned, progress, op);
245
+ step(`release:${planned.project.id}`);
246
+ progress.releaseRequested = true; save();
247
+ await driver.release(planned.project, id, progress.instanceId!);
248
+ progress.phase = "verified"; save();
249
+ }
250
+ if (driver.refreshManager) { step("refresh-manager"); await driver.refreshManager(op); }
251
+ if (op.plan.kind === "upgrade" && !op.globalInstalled) {
252
+ step("install-global"); await driver.installGlobal(op); op.globalInstalled = true; save();
253
+ }
254
+ releaseRecoveryLock(id, home); op.phase = "completed"; step("completed");
255
+ } catch (error) {
256
+ op.phase = "blocked"; op.error = error instanceof Error ? error.message : "recovery failed"; save();
257
+ } finally { releaseRunner(); }
258
+ return op;
259
+ }
260
+
261
+ export function publicOperation(op: RecoveryOperation) {
262
+ return { id: op.id, phase: op.phase, step: op.step, version: op.plan.version, updatedAt: op.updatedAt,
263
+ projects: op.projects.map((p) => ({ id: p.id, phase: p.phase })), ...(op.error ? { error: op.error } : {}) };
264
+ }
265
+
266
+ /** Escape a blocked preflight without abandoning a stopped runtime or an uncertain terminal mutation. */
267
+ export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
268
+ let op = readOperation<RecoveryOperation>(id, home);
269
+ validateReceipt(id, op);
270
+ acquireRecoveryLock(id, home);
271
+ const releaseRunner = claimRunner(id, home);
272
+ try {
273
+ // Re-read after the exclusive runner claim so cancellation cannot act on a
274
+ // stale pending/prepared receipt after another runner advanced it.
275
+ op = readOperation<RecoveryOperation>(id, home);
276
+ validateReceipt(id, op);
277
+ if (op.phase === "completed" || op.phase === "cancelled") {
278
+ if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
279
+ return;
280
+ }
281
+ if (op.projects.some((p) => !["pending", "prepared"].includes(p.phase) || Object.keys(p.terminals).length)) {
282
+ throw new Error("operation has stopped runtimes or uncertain terminal effects; resume it instead");
283
+ }
284
+ const { fingerprint, ...body } = op.plan;
285
+ if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
286
+ for (const planned of op.plan.projects) {
287
+ const live = await driver.inspect(planned.project);
288
+ if (live.recovery?.operationId === id) {
289
+ if (live.instanceId !== planned.source.instanceId || !["preparing", "prepared"].includes(live.recovery.phase ?? "")) throw new Error("recovery has progressed; resume it instead");
290
+ await driver.abort(planned.project, id, planned.source.instanceId!);
291
+ } else if (op.projects.find((p) => p.id === planned.project.id)?.phase === "prepared") {
292
+ throw new Error("prepared source outcome is uncertain; resume it instead");
293
+ }
294
+ }
295
+ op.phase = "cancelled"; op.step = "cancelled"; op.updatedAt = driver.now();
296
+ writeOperation(id, op, home); releaseRecoveryLock(id, home);
297
+ } finally { releaseRunner(); }
298
+ }