@staix/agent-hub 0.4.0 → 0.5.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.
@@ -7,6 +7,7 @@ import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
7
7
  import { startDaemon } from "./daemon.ts";
8
8
  import { Registry, type Project } from "./registry.ts";
9
9
  import { CODEX_APP, CODEX_PROXY, CONTROL, SWITCHYARD } from "./ports.ts";
10
+ import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
10
11
 
11
12
  export type ProjectInspection = {
12
13
  state: "running" | "stopped" | "stopping" | "unavailable" | "incompatible" | "missing" | "starting";
@@ -85,6 +86,7 @@ async function available(base: number): Promise<boolean> {
85
86
 
86
87
  /** Called only in the detached daemon process. A competing starter exits without touching state. */
87
88
  export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
89
+ assertLifecycleAvailable();
88
90
  const registry = new Registry();
89
91
  const instanceId = randomUUID();
90
92
  let claimed = false;
@@ -122,6 +124,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
122
124
  }
123
125
 
124
126
  export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
127
+ assertLifecycleAvailable();
125
128
  const registry = new Registry();
126
129
  try {
127
130
  const current = registry.get(project.id);
@@ -138,10 +141,14 @@ export async function startProject(project: Project, options: { unattended?: boo
138
141
  renameSync(temporary, marker);
139
142
  const log = openSync(join(project.stateDir, "hub.log"), "a");
140
143
  try {
144
+ const launchEnv = { ...(options.env ?? process.env) };
145
+ // Native sessions can outlive the operation which launched them. An expired
146
+ // operation ID must not make a later ordinary startup load a retired snapshot.
147
+ if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
141
148
  const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
142
149
  ...(options.unattended ? ["--unattended"] : [])], {
143
150
  cwd: project.root, detached: true, stdio: ["ignore", log, log],
144
- env: { ...(options.env ?? process.env), AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
151
+ env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
145
152
  AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
146
153
  });
147
154
  child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
@@ -160,6 +167,7 @@ export async function startProject(project: Project, options: { unattended?: boo
160
167
  }
161
168
 
162
169
  export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
170
+ assertLifecycleAvailable();
163
171
  const inspection = await inspectProject(project);
164
172
  if (inspection.state === "stopped") return;
165
173
  if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
package/src/hub/peers.ts CHANGED
@@ -15,6 +15,8 @@ export interface PeerAdapter {
15
15
  onState?: (state: PeerState) => void;
16
16
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
17
17
  onFailed?: (envs: Envelope[]) => void;
18
+ /** Safe restart metadata only: ids and launch parameters, never prompt/message text. */
19
+ recoveryMetadata?(): Record<string, unknown>;
18
20
  }
19
21
 
20
22
  export const DEFAULT_WATCHDOG_MS = 300_000;
@@ -0,0 +1,89 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { Database } from "bun:sqlite";
5
+ import { hubHome } from "./project.ts";
6
+
7
+ export const operationIdPattern = /^[a-f0-9-]{36}$/;
8
+ export function operationPath(id: string, home = hubHome()): string {
9
+ if (!operationIdPattern.test(id)) throw new Error("invalid recovery operation ID");
10
+ return join(home, "recovery", `${id}.json`);
11
+ }
12
+
13
+ /** Private state only. Never put tokens or transcript/message bodies in an operation receipt. */
14
+ export function atomicPrivateJSON(file: string, value: unknown): void {
15
+ const temporary = `${file}.${randomUUID()}.tmp`;
16
+ try {
17
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: "wx" });
18
+ chmodSync(temporary, 0o600);
19
+ renameSync(temporary, file);
20
+ } finally { rmSync(temporary, { force: true }); }
21
+ }
22
+
23
+ export function readOperation<T>(id: string, home = hubHome()): T {
24
+ return JSON.parse(readFileSync(operationPath(id, home), "utf8")) as T;
25
+ }
26
+
27
+ export function writeOperation(id: string, value: unknown, home = hubHome()): void {
28
+ mkdirSync(join(home, "recovery"), { recursive: true, mode: 0o700 });
29
+ atomicPrivateJSON(operationPath(id, home), value);
30
+ }
31
+
32
+ export function recoveryLock(home = hubHome()): string | undefined {
33
+ const file = join(home, "recovery.lock");
34
+ try {
35
+ const data = JSON.parse(readFileSync(file, "utf8"));
36
+ if (!operationIdPattern.test(data.operationId)) throw new Error("invalid recovery lock");
37
+ return data.operationId;
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw new Error("recovery lock is unreadable; inspect it before changing runtimes", { cause: error });
41
+ }
42
+ }
43
+
44
+ export function acquireRecoveryLock(id: string, home = hubHome()): void {
45
+ operationPath(id, home);
46
+ mkdirSync(home, { recursive: true, mode: 0o700 });
47
+ const existing = recoveryLock(home);
48
+ if (existing === id) return;
49
+ try { writeFileSync(join(home, "recovery.lock"), JSON.stringify({ operationId: id }), { mode: 0o600, flag: "wx" }); }
50
+ catch { throw new Error(`another recovery operation owns this machine: ${recoveryLock(home) ?? "unknown"}`); }
51
+ }
52
+
53
+ export function releaseRecoveryLock(id: string, home = hubHome()): void {
54
+ if (recoveryLock(home) !== id) throw new Error("recovery lock ownership changed");
55
+ rmSync(join(home, "recovery.lock"));
56
+ }
57
+
58
+ /** Used by CLI and manager lifecycle paths. A detached operation carries its own ID. */
59
+ export function assertLifecycleAvailable(): void {
60
+ const owner = recoveryLock();
61
+ if (owner && process.env.AGENTHUB_RECOVERY_OPERATION !== owner) {
62
+ throw new Error(`recovery operation ${owner} is active; use ahub recovery status|resume ${owner}`);
63
+ }
64
+ }
65
+
66
+ /** An exclusive runner claim. Never steal a live or uncertain owner on resume. */
67
+ export function claimRunner(id: string, home = hubHome()): () => void {
68
+ const path = `${operationPath(id, home)}.runner.db`;
69
+ const db = new Database(path, { create: true });
70
+ chmodSync(path, 0o600);
71
+ const nonce = randomUUID();
72
+ try {
73
+ db.run("PRAGMA busy_timeout = 3000");
74
+ db.run("CREATE TABLE IF NOT EXISTS runner (slot INTEGER PRIMARY KEY, pid INTEGER NOT NULL, nonce TEXT NOT NULL)");
75
+ db.transaction(() => {
76
+ const row = db.query("SELECT pid FROM runner WHERE slot = 1").get() as { pid: number } | null;
77
+ if (row) {
78
+ if (!Number.isSafeInteger(row.pid) || row.pid < 1) throw new Error("invalid recovery runner ownership");
79
+ try { process.kill(row.pid, 0); throw new Error(`recovery runner ${row.pid} is still alive`); }
80
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
81
+ }
82
+ db.query("INSERT OR REPLACE INTO runner (slot, pid, nonce) VALUES (1, ?, ?)").run(process.pid, nonce);
83
+ }).immediate();
84
+ } catch (error) { db.close(); throw error; }
85
+ return () => {
86
+ db.query("DELETE FROM runner WHERE slot = 1 AND nonce = ?").run(nonce);
87
+ db.close();
88
+ };
89
+ }
@@ -0,0 +1,97 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import type { BusSnapshot } from "./bus.ts";
5
+ import type { PeerId, PeerState } from "./envelope.ts";
6
+ import { MAX_HOP } from "./envelope.ts";
7
+
8
+ export const RESTART_SCHEMA_VERSION = 1;
9
+
10
+ export type RecoveryPhase = "preparing" | "prepared" | "restored" | "released";
11
+
12
+ export interface RestartPeerSnapshot {
13
+ id: PeerId;
14
+ state: PeerState;
15
+ queueIds: string[];
16
+ launch?: Record<string, unknown>;
17
+ /** Safe native correlation metadata. Never include prompt/message text here. */
18
+ threadId?: string;
19
+ sessionId?: string;
20
+ }
21
+
22
+ export interface RestartSnapshot {
23
+ schemaVersion: 1;
24
+ projectRoot: string;
25
+ projectId: string;
26
+ sourceInstanceId: string;
27
+ operationId: string;
28
+ committedAt: number;
29
+ bus: BusSnapshot;
30
+ manualPaused: PeerId[];
31
+ peers: RestartPeerSnapshot[];
32
+ integrity?: {
33
+ queues: Record<string, string[]>;
34
+ manualPaused: PeerId[];
35
+ boardDigest: string;
36
+ budgetDigest: string;
37
+ };
38
+ }
39
+
40
+ export interface RestartReadExpectation {
41
+ projectRoot: string;
42
+ projectId: string;
43
+ operationId?: string;
44
+ }
45
+
46
+ export function restartPath(stateDir: string): string {
47
+ return join(stateDir, "restart.json");
48
+ }
49
+
50
+ export function releasedRestartPath(stateDir: string, operationId: string): string {
51
+ const safe = createHash("sha256").update(operationId).digest("hex");
52
+ return join(stateDir, `restart.released.${safe}.json`);
53
+ }
54
+
55
+ /** Read only a matching controlled-restart snapshot. Unknown or malformed state is ignored and must block recovery upstream. */
56
+ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpectation): RestartSnapshot | undefined {
57
+ let parsed: unknown;
58
+ try { parsed = JSON.parse(readFileSync(restartPath(stateDir), "utf8")); } catch { return undefined; }
59
+ if (!parsed || typeof parsed !== "object") return undefined;
60
+ const s = parsed as Partial<RestartSnapshot>;
61
+ if (s.schemaVersion !== RESTART_SCHEMA_VERSION || s.projectRoot !== expected.projectRoot || s.projectId !== expected.projectId || typeof s.operationId !== "string" || !s.operationId || typeof s.sourceInstanceId !== "string" || !s.sourceInstanceId || !s.bus) return undefined;
62
+ if (expected.operationId !== undefined && s.operationId !== expected.operationId) return undefined;
63
+ const peerId = (value: unknown): value is string => typeof value === "string" && /^(?:hub|user|[a-z][a-z0-9-]{0,31})$/.test(value);
64
+ const isEnvelope = (value: unknown, seen = false): boolean => {
65
+ if (!value || typeof value !== "object") return false;
66
+ const e = value as Record<string, unknown>;
67
+ return typeof e.id === "string" && e.id.length > 0 && e.id.length <= 256 && typeof e.trace === "string" && e.trace.length > 0 && e.trace.length <= 256 && peerId(e.from) && typeof e.body === "string" && Number.isSafeInteger(e.hop) && (e.hop as number) >= 0 && (seen || (e.hop as number) <= MAX_HOP) && Number.isFinite(e.ts) && ["chat", "task", "review", "status", "budget", "presence"].includes(e.kind as string) && ["important", "status", "fyi"].includes(e.priority as string) && (e.to === undefined || (Array.isArray(e.to) && e.to.every(peerId)));
68
+ };
69
+ const bus = s.bus as Partial<BusSnapshot>;
70
+ if (bus.schemaVersion !== 1 || !bus.queues || typeof bus.queues !== "object" || !bus.prefaces || typeof bus.prefaces !== "object" || !Array.isArray(bus.seen) || !bus.attempts || typeof bus.attempts !== "object" || !Array.isArray(bus.withdrawn)) return undefined;
71
+ if (!Object.keys(bus.queues).every(peerId) || !Object.keys(bus.prefaces).every(peerId) || !Object.values(bus.queues).every((queue) => Array.isArray(queue) && queue.every((e) => isEnvelope(e))) || !Object.values(bus.prefaces).every((e) => isEnvelope(e)) || !bus.seen.every((e) => isEnvelope(e, true)) || !bus.withdrawn.every((id) => typeof id === "string" && id.length > 0) || !Object.values(bus.attempts).every((n) => Number.isSafeInteger(n) && n > 0)) return undefined;
72
+ if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
73
+ if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
74
+ return s as RestartSnapshot;
75
+ }
76
+
77
+ /** Atomic, owner-readable recovery state. The file never carries delivery bodies outside this 0600 path. */
78
+ export function writeRestartSnapshot(stateDir: string, snapshot: RestartSnapshot): void {
79
+ mkdirSync(stateDir, { recursive: true });
80
+ const file = restartPath(stateDir);
81
+ const tmp = `${file}.${snapshot.sourceInstanceId}.tmp`;
82
+ writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
83
+ chmodSync(tmp, 0o600);
84
+ renameSync(tmp, file);
85
+ chmodSync(file, 0o600);
86
+ }
87
+
88
+ export function removeRestartSnapshot(stateDir: string): void {
89
+ try { unlinkSync(restartPath(stateDir)); }
90
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
91
+ }
92
+
93
+ /** Preserve an uncertain release outcome for manual reconciliation without making it authoritative on startup. */
94
+ export function archiveRestartSnapshot(stateDir: string, operationId: string): void {
95
+ renameSync(restartPath(stateDir), releasedRestartPath(stateDir, operationId));
96
+ chmodSync(releasedRestartPath(stateDir, operationId), 0o600);
97
+ }