@staix/agent-hub 0.25.0 → 0.26.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.
- package/CHANGELOG.md +10 -0
- package/README.md +10 -5
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/daemon.md +6 -4
- package/docs/agent-notes/tasks.md +5 -4
- package/docs/agent-notes/tests.md +1 -0
- package/docs/dogfood.md +4 -0
- package/docs/operations.md +131 -14
- package/docs/quickstart.md +14 -4
- package/docs/security.md +51 -8
- package/docs/specs/2026-11-01-research-campaigns-design.md +9 -6
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +28 -16
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +182 -95
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +1 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/help.ts +7 -7
- package/src/cli/main.ts +62 -23
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/task-output.ts +5 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +129 -13
- package/src/hub/budget.ts +3 -4
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +30 -10
- package/src/hub/daemon.ts +82 -17
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/execution-budget.ts +3 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +49 -33
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-experiments.ts +33 -4
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +9 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +3 -4
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +133 -34
- package/src/hub/usage-rollup.ts +3 -4
- package/src/pi/tool-receipts.ts +3 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { constants, Database } from "bun:sqlite";
|
|
2
|
+
export type { Database } from "bun:sqlite";
|
|
3
|
+
import { closeSync, lstatSync, openSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { VERSION } from "../version.ts";
|
|
6
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
|
+
|
|
8
|
+
/** Bump only for an incompatible hub.db format; compatible releases keep the introducing version. */
|
|
9
|
+
export const HUB_DB_SCHEMA = 2;
|
|
10
|
+
/** New/legacy databases keep format 1 until an incompatible record is committed. */
|
|
11
|
+
export const HUB_DB_BASE_SCHEMA = 1;
|
|
12
|
+
/** schema is the maximum supported format, never a request to migrate an existing compatible database. */
|
|
13
|
+
export interface HubDbSchemaReader { schema: number; version: string }
|
|
14
|
+
export interface HubDatabaseOptions {
|
|
15
|
+
readonly?: boolean;
|
|
16
|
+
create?: boolean;
|
|
17
|
+
reader?: HubDbSchemaReader;
|
|
18
|
+
}
|
|
19
|
+
export class HubDbSchemaError extends Error {
|
|
20
|
+
readonly code = "HUB_DB_SCHEMA";
|
|
21
|
+
constructor(message: string, readonly requiredVersion?: string) { super(message); this.name = "HubDbSchemaError"; }
|
|
22
|
+
}
|
|
23
|
+
interface HubDbSchema { schema: number; requiredVersion: string }
|
|
24
|
+
const VERSION_TEXT = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/;
|
|
25
|
+
const safeVersion = (value: unknown): value is string => typeof value === "string" && value.length <= 64 && VERSION_TEXT.exec(value)?.[0] === value;
|
|
26
|
+
const defaultReader = (): HubDbSchemaReader => ({ schema: HUB_DB_SCHEMA, version: VERSION });
|
|
27
|
+
const invalidSchema = () => new HubDbSchemaError("this project's hub.db schema metadata is invalid; inspect or restore the database before starting agent-hub");
|
|
28
|
+
const busy = (error: unknown): boolean => {
|
|
29
|
+
const value = error as { code?: unknown; message?: unknown } | null;
|
|
30
|
+
return typeof value?.code === "string" && /^SQLITE_(BUSY|LOCKED)(?:_|$)/.test(value.code)
|
|
31
|
+
|| typeof value?.message === "string" && /database (?:is )?(?:locked|busy)|database table is locked/i.test(value.message);
|
|
32
|
+
};
|
|
33
|
+
function readerValue(reader: HubDbSchemaReader): HubDbSchemaReader {
|
|
34
|
+
if (!reader || !Number.isSafeInteger(reader.schema) || reader.schema < 1 || !safeVersion(reader.version)) throw invalidSchema();
|
|
35
|
+
return { schema: reader.schema, version: reader.version };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** No persistent PRAGMA or schema/data write runs while reading the marker. */
|
|
39
|
+
function schemaOn(db: Database): HubDbSchema | undefined {
|
|
40
|
+
try {
|
|
41
|
+
const entries = db.query("SELECT type FROM sqlite_master WHERE name='hub_schema' COLLATE NOCASE LIMIT 2").all() as { type: string }[];
|
|
42
|
+
if (!entries.length) return undefined;
|
|
43
|
+
if (entries.length !== 1 || entries[0]!.type !== "table") throw invalidSchema();
|
|
44
|
+
const rows = db.query("SELECT id,schema,CASE WHEN typeof(required_version)='text' AND length(CAST(required_version AS BLOB))<=64 THEN required_version END AS required_version FROM hub_schema LIMIT 2")
|
|
45
|
+
.all() as { id: number; schema: number; required_version: string | null }[];
|
|
46
|
+
const row = rows[0];
|
|
47
|
+
if (rows.length !== 1 || !row || row.id !== 1 || !Number.isSafeInteger(row.schema) || row.schema < 1 || !safeVersion(row.required_version)) throw invalidSchema();
|
|
48
|
+
return { schema: row.schema, requiredVersion: row.required_version };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Native corruption is unreadable storage, not a parsed schema claim; reset may archive it intact.
|
|
51
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
52
|
+
if (error instanceof HubDbSchemaError || busy(error) || typeof code === "string" && /^(?:SQLITE_NOTADB|SQLITE_CORRUPT)(?:_|$)/.test(code)) throw error;
|
|
53
|
+
throw invalidSchema();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function checkOn(db: Database, reader: HubDbSchemaReader): HubDbSchema | undefined {
|
|
57
|
+
const stored = schemaOn(db);
|
|
58
|
+
if (stored && stored.schema > reader.schema) throw new HubDbSchemaError(`this project was opened by agent-hub ${stored.requiredVersion}; use ${stored.requiredVersion} or later`, stored.requiredVersion);
|
|
59
|
+
return stored;
|
|
60
|
+
}
|
|
61
|
+
function existingFile(file: string): boolean {
|
|
62
|
+
if (file === ":memory:") return false;
|
|
63
|
+
let info;
|
|
64
|
+
try { info = lstatSync(file); }
|
|
65
|
+
catch (error) { if ((error as { code?: string }).code === "ENOENT") return false; throw error; }
|
|
66
|
+
// A dangling link or non-file is unknown storage, never a missing legacy database.
|
|
67
|
+
if (info.isSymbolicLink()) { try { info = statSync(file); } catch { throw invalidSchema(); } }
|
|
68
|
+
if (!info.isFile()) throw invalidSchema();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Closed, checkpointed WAL files need a quiet metadata probe on macOS; unknown file/mode state never qualifies. */
|
|
73
|
+
function checkpointedWal(file: string): boolean {
|
|
74
|
+
let fd: number | undefined;
|
|
75
|
+
try {
|
|
76
|
+
fd = openSync(file, "r");
|
|
77
|
+
const header = Buffer.alloc(20);
|
|
78
|
+
return readSync(fd, header, 0, header.length, 0) === header.length && header.toString("ascii", 0, 15) === "SQLite format 3"
|
|
79
|
+
&& header[15] === 0 && header[18] === 2 && header[19] === 2;
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
82
|
+
}
|
|
83
|
+
function noSidecars(file: string): boolean {
|
|
84
|
+
for (const suffix of ["-wal", "-shm", "-journal"]) {
|
|
85
|
+
try { lstatSync(`${file}${suffix}`); return false; }
|
|
86
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") return false; }
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function probeCompatible(file: string, reader: HubDbSchemaReader, quiet: boolean): void {
|
|
91
|
+
const uri = pathToFileURL(file); uri.searchParams.set("immutable", "1");
|
|
92
|
+
const db = quiet ? new Database(uri.href, constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI) : new Database(file, { readonly: true, create: false });
|
|
93
|
+
try {
|
|
94
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`); // connection-local, not persisted
|
|
95
|
+
checkOn(db, reader);
|
|
96
|
+
} finally { db.close(); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Live WAL reads may take SHM read locks. Refusal performs no SQL/schema/data or DB/WAL write; SHM locks are SQLite's ceiling. */
|
|
100
|
+
export function assertHubDbCompatible(file: string, reader: HubDbSchemaReader = defaultReader()): void {
|
|
101
|
+
const supported = readerValue(reader);
|
|
102
|
+
if (!existingFile(file)) return;
|
|
103
|
+
if (checkpointedWal(file) && noSidecars(file)) {
|
|
104
|
+
// This handle is short-lived and never escapes. A live returned handle must continue observing later WAL commits.
|
|
105
|
+
let failure: unknown, failed = false;
|
|
106
|
+
try { probeCompatible(file, supported, true); } catch (error) { failure = error; failed = true; }
|
|
107
|
+
if (!noSidecars(file)) { probeCompatible(file, supported, false); return; }
|
|
108
|
+
if (failed) throw failure;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
probeCompatible(file, supported, false);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every hub.db writer enters here before its own persistent PRAGMAs, migrations or maintenance writes. */
|
|
115
|
+
export function openHubDatabase(file: string, options: HubDatabaseOptions = {}): Database {
|
|
116
|
+
const reader = readerValue(options.reader ?? defaultReader());
|
|
117
|
+
// Refuse a future file without opening a writable connection, which can perform SQLite recovery work itself.
|
|
118
|
+
assertHubDbCompatible(file, reader);
|
|
119
|
+
const db = new Database(file, options.readonly ? { readonly: true, create: false } : { readwrite: true, create: options.create ?? true });
|
|
120
|
+
try {
|
|
121
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
122
|
+
if (options.readonly) { checkOn(db, reader); return db; }
|
|
123
|
+
// The second read and legacy baseline stamp share the writer lock, without filesystem work in SQL.
|
|
124
|
+
// Existing compatible markers never advance here; an incompatible migration must advance its marker with its actual DDL/data transaction.
|
|
125
|
+
db.transaction(() => {
|
|
126
|
+
const stored = checkOn(db, reader);
|
|
127
|
+
if (!stored) {
|
|
128
|
+
db.run("CREATE TABLE hub_schema (id INTEGER PRIMARY KEY CHECK(id=1),schema INTEGER NOT NULL,required_version TEXT NOT NULL)");
|
|
129
|
+
db.query("INSERT INTO hub_schema(id,schema,required_version) VALUES(1,?,?)").run(HUB_DB_BASE_SCHEMA, VERSION);
|
|
130
|
+
}
|
|
131
|
+
}).immediate();
|
|
132
|
+
return db;
|
|
133
|
+
} catch (error) { db.close(); throw error; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The format marker advances only in the same transaction as the incompatible data it protects. */
|
|
137
|
+
export function advanceHubDbSchema(db: Database, schema: number, requiredVersion: string): void {
|
|
138
|
+
if (!db.inTransaction || !Number.isSafeInteger(schema) || schema < HUB_DB_BASE_SCHEMA || schema > HUB_DB_SCHEMA || !safeVersion(requiredVersion))
|
|
139
|
+
throw new HubDbSchemaError("hub.db schema advance requires an active data transaction and a supported format");
|
|
140
|
+
const stored = checkOn(db, defaultReader());
|
|
141
|
+
if (!stored) throw invalidSchema();
|
|
142
|
+
if (stored.schema < schema) db.query("UPDATE hub_schema SET schema=?,required_version=? WHERE id=1").run(schema, requiredVersion);
|
|
143
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import { Database } from "
|
|
3
|
+
import type { Database } from "./db-schema.ts";
|
|
4
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
4
5
|
import type { BusSnapshot } from "./bus.ts";
|
|
5
6
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
6
7
|
import { HUB, KINDS, USER, type Envelope, type Kind } from "./envelope.ts";
|
|
7
8
|
import { heldDeliveryHints } from "./delivery-hints.ts";
|
|
8
9
|
import { processLiveness } from "../pi/process-signature.ts";
|
|
9
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
10
10
|
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
11
11
|
import { validateDeferredHub } from "./deferred-hub.ts";
|
|
12
12
|
|
|
@@ -282,8 +282,7 @@ export class DeliveryJournal {
|
|
|
282
282
|
this.operationId = options.operationId;
|
|
283
283
|
this.terminalHistoryLimit = options.terminalHistoryLimit;
|
|
284
284
|
mkdirSync(dirname(options.file), { recursive: true, mode: 0o700 });
|
|
285
|
-
this.db =
|
|
286
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
285
|
+
this.db = openHubDatabase(options.file, { create: true });
|
|
287
286
|
this.harden();
|
|
288
287
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
289
288
|
this.db.run("PRAGMA synchronous = FULL");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { definitionOf, type KindMap } from "../adapters/registry.ts";
|
|
2
|
-
import { Database } from "
|
|
3
|
-
import {
|
|
2
|
+
import type { Database } from "./db-schema.ts";
|
|
3
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
4
4
|
|
|
5
5
|
export type ExecutionUnit = "model_calls" | "tool_calls" | "elapsed_ms" | "tokens";
|
|
6
6
|
export type ExecutionBudgetLimits = Partial<Record<ExecutionUnit, number>>;
|
|
@@ -43,8 +43,7 @@ const validLimits = (raw: ExecutionBudgetLimits): ExecutionBudgetLimits => {
|
|
|
43
43
|
export class ExecutionBudget {
|
|
44
44
|
private readonly db: Database;
|
|
45
45
|
constructor(path: string, private readonly instances?: KindMap) {
|
|
46
|
-
this.db =
|
|
47
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
46
|
+
this.db = openHubDatabase(path, { create: true });
|
|
48
47
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
49
48
|
this.db.run(`CREATE TABLE IF NOT EXISTS execution_budgets (
|
|
50
49
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, task_id INTEGER, peers TEXT NOT NULL,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Database } from "
|
|
1
|
+
import type { Database } from "./db-schema.ts";
|
|
2
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
2
3
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
|
|
3
4
|
import { join, resolve, sep } from "node:path";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { isDenied } from "../local/deny.ts";
|
|
6
7
|
import { checkCorpusRoot } from "./corpus.ts";
|
|
7
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
8
8
|
import { realPath } from "./project.ts";
|
|
9
9
|
import { folderGlobMatches, MAX_NEW_PER_POLL, type FolderEntry, type FolderRunResult, type FolderScan, type ScannedFile } from "./schedules.ts";
|
|
10
10
|
import { MAX_SOURCE_BYTES, screenRegistration, type SourceRegistry } from "./sources.ts";
|
|
@@ -169,8 +169,7 @@ export interface FolderSeenRow { trigger: string; real_path: string; size: numbe
|
|
|
169
169
|
export class FolderStore {
|
|
170
170
|
private readonly db: Database;
|
|
171
171
|
constructor(dbPath: string) {
|
|
172
|
-
this.db =
|
|
173
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
172
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
174
173
|
this.db.run("CREATE TABLE IF NOT EXISTS folder_seen (trigger TEXT NOT NULL, real_path TEXT NOT NULL, size INTEGER NOT NULL, mtime INTEGER NOT NULL, sha256 TEXT NOT NULL, task INTEGER, at INTEGER NOT NULL, PRIMARY KEY (trigger, real_path, sha256))");
|
|
175
174
|
this.db.run("CREATE INDEX IF NOT EXISTS folder_seen_hash ON folder_seen (trigger, sha256)");
|
|
176
175
|
this.db.run("CREATE TABLE IF NOT EXISTS folder_poll (trigger TEXT PRIMARY KEY, started INTEGER NOT NULL, baselined INTEGER NOT NULL DEFAULT 0, last_poll INTEGER, last_error TEXT)");
|
package/src/hub/invocations.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// #445 C2: read invocations. A peer asks the hub to have another peer run one capability the person shared; the target runs it in its own turn with its own
|
|
2
2
|
// permissions, and the hub returns a bounded, sanitized, hub-headed result to the caller only. This file is the pure part (the gates, the argument check, the
|
|
3
3
|
// two texts the hub writes) and the ledger (an ids-only `invocations` table in hub.db). It never reads a peer, a socket or the bus.
|
|
4
|
-
import { Database } from "
|
|
4
|
+
import type { Database } from "./db-schema.ts";
|
|
5
|
+
import { openHubDatabase } from "./db-schema.ts";
|
|
5
6
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
7
7
|
import { MAX_HOP, sanitize } from "./envelope.ts";
|
|
8
8
|
import type { FlatSchema, ShareState, SideEffect } from "./capabilities.ts";
|
|
9
9
|
|
|
@@ -230,8 +230,7 @@ function validateRelay(row: InvocationRow, relay: Omit<InvocationRelay, "local"
|
|
|
230
230
|
export class InvocationLedger {
|
|
231
231
|
private readonly db: Database;
|
|
232
232
|
constructor(path: string, private readonly now: () => number = Date.now) {
|
|
233
|
-
this.db =
|
|
234
|
-
this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
233
|
+
this.db = openHubDatabase(path, { create: true });
|
|
235
234
|
this.db.run("PRAGMA journal_mode = WAL");
|
|
236
235
|
this.db.run(`CREATE TABLE IF NOT EXISTS invocations (
|
|
237
236
|
id TEXT PRIMARY KEY, trace TEXT NOT NULL, caller TEXT NOT NULL, target TEXT NOT NULL, capability TEXT NOT NULL, digest TEXT NOT NULL,
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import { resolveProjectRuntime, recordProjectRuntime, confirmProjectRuntime, readProjectRuntimeForCaller, projectRuntimeRefusal, type ProjectRuntimeSelection } from "./project-runtime.ts";
|
|
2
|
+
import { scheduleRuntimeCapturePrune } from "./runtime-capture-prune.ts";
|
|
3
|
+
import { createProjectRuntimeAccess, type ProjectRuntimeAccess } from "./project-runtime-state.ts";
|
|
1
4
|
import { isDatabaseBusyReply, RecoveryDatabaseBusy } from "./recovery-error.ts";
|
|
2
5
|
import { spawn } from "node:child_process";
|
|
3
6
|
import { randomUUID } from "node:crypto";
|
|
4
7
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
6
9
|
import { createServer, type Server } from "node:net";
|
|
7
|
-
import { ControlClient, PROTOCOL, readControl } from "./control-client.ts";
|
|
10
|
+
import { ControlClient, PROTOCOL, PROTOCOL_MIN, controlProtocolWindow, readControl } from "./control-client.ts";
|
|
11
|
+
import { negotiateProtocol, protocolWindow } from "./protocol-window.ts";
|
|
12
|
+
import { assertHubDbCompatible } from "./db-schema.ts";
|
|
13
|
+
import { verifyPackage, verifyRecoveryTarget } from "../cli/recovery-package.ts";
|
|
14
|
+
import { VERSION } from "../version.ts";
|
|
8
15
|
import { startDaemon } from "./daemon.ts";
|
|
9
16
|
import { terminalOpener } from "./terminal-open.ts";
|
|
10
17
|
import { Registry, type Project } from "./registry.ts";
|
|
11
18
|
import { CODEX_APP, CODEX_PROXY, CONTROL } from "./ports.ts";
|
|
12
|
-
import { assertLifecycleAvailable, recoveryLock } from "./recovery-store.ts";
|
|
19
|
+
import { assertLifecycleAvailable, readOperation, recoveryLock } from "./recovery-store.ts";
|
|
20
|
+
import { realPath } from "./project.ts";
|
|
13
21
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
14
22
|
|
|
15
23
|
export type ProjectInspection = {
|
|
@@ -25,7 +33,9 @@ export async function inspectProject(project: Project, timeoutMs = 3000, readerP
|
|
|
25
33
|
const control = readControl(project.stateDir);
|
|
26
34
|
if (control) {
|
|
27
35
|
if (processLiveness(control.pid, control.pidSignature) === "gone") return { state: "stopped" };
|
|
28
|
-
|
|
36
|
+
const advertised = controlProtocolWindow(control);
|
|
37
|
+
if ((control.protocol !== undefined || control.protocolMin !== undefined || control.protocolMax !== undefined)
|
|
38
|
+
&& (!advertised || negotiateProtocol(protocolWindow(PROTOCOL, PROTOCOL_MIN)!, advertised) === undefined)) {
|
|
29
39
|
return { state: "incompatible", error: `hub protocol ${control.protocol}; stop with its matching CLI before upgrading` };
|
|
30
40
|
}
|
|
31
41
|
if ((control.projectId && control.projectId !== project.id) || control.cwd !== project.root) {
|
|
@@ -87,9 +97,24 @@ async function available(base: number): Promise<boolean> {
|
|
|
87
97
|
/** The signals the project daemon stops on (see `runProjectDaemon`). */
|
|
88
98
|
export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
|
|
89
99
|
|
|
100
|
+
function startupRuntime(project: Project, env: NodeJS.ProcessEnv, registry: Registry, access: ProjectRuntimeAccess) {
|
|
101
|
+
const sourceRoot = resolve(import.meta.dir, "../.."), id = env.AGENTHUB_RECOVERY_OPERATION;
|
|
102
|
+
if (id && recoveryLock(undefined, project.root) === id) {
|
|
103
|
+
const op = readOperation<{ targetRoot?: string; targetDigest?: string; plan: { kind: "restart" | "upgrade"; version: string; projects: { project: Project }[] } }>(id);
|
|
104
|
+
if (!op.targetRoot || !op.targetDigest || op.plan.version !== VERSION || realPath(op.targetRoot) !== realPath(sourceRoot)
|
|
105
|
+
|| !op.plan.projects.some(row => row.project.id === project.id && row.project.root === project.root && row.project.stateDir === project.stateDir))
|
|
106
|
+
throw new Error("recovery startup does not match its retained target runtime");
|
|
107
|
+
// Authority binds this exact target path. Do not re-stage it at a different, byte-identical path.
|
|
108
|
+
return { root: sourceRoot, digest: op.targetDigest, version: VERSION, kind: op.plan.kind, check: () => verifyRecoveryTarget(op.plan.kind, sourceRoot, VERSION) };
|
|
109
|
+
}
|
|
110
|
+
const retained = resolveProjectRuntime(project, VERSION, sourceRoot, registry, access);
|
|
111
|
+
return { ...retained, check: () => verifyRecoveryTarget(retained.kind, retained.root, retained.version) };
|
|
112
|
+
}
|
|
113
|
+
|
|
90
114
|
/** Called only in the detached daemon process. A competing starter exits without touching state. */
|
|
91
115
|
export async function runProjectDaemon(project: Project, unattended = false): Promise<void> {
|
|
92
|
-
assertLifecycleAvailable();
|
|
116
|
+
assertLifecycleAvailable(project.root);
|
|
117
|
+
assertHubDbCompatible(join(project.stateDir, "hub.db"));
|
|
93
118
|
const registry = new Registry();
|
|
94
119
|
const instanceId = randomUUID();
|
|
95
120
|
let claimed = false;
|
|
@@ -108,11 +133,19 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
108
133
|
forcedExit.unref();
|
|
109
134
|
};
|
|
110
135
|
try {
|
|
136
|
+
const runtimeAccess = createProjectRuntimeAccess(project);
|
|
111
137
|
const before = await inspectProject(registry.get(project.id) ?? project);
|
|
112
138
|
if (before.state === "running" || before.state === "starting") return;
|
|
113
139
|
if (before.state !== "stopped") throw new Error(before.error ?? before.state);
|
|
114
140
|
claimed = registry.claim(project.id, instanceId, process.pid, processSignature(process.pid));
|
|
115
141
|
if (!claimed) return;
|
|
142
|
+
registry.runtimeCaptureLocked(() => {
|
|
143
|
+
const runtime = startupRuntime(project, process.env, registry, runtimeAccess);
|
|
144
|
+
// Direct internal daemon entry must not bypass the selected runtime. Normal up already spawned its verified capture.
|
|
145
|
+
if (realPath(resolve(import.meta.dir, "../..")) !== runtime.root || verifyPackage(resolve(import.meta.dir, "../.."), VERSION) !== runtime.digest)
|
|
146
|
+
throw projectRuntimeRefusal(runtime.version, "start with ahub up so the recorded runtime is used");
|
|
147
|
+
recordProjectRuntime(project, runtime, instanceId, registry, runtimeAccess);
|
|
148
|
+
});
|
|
116
149
|
let base = registry.get(project.id)!.basePort;
|
|
117
150
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
118
151
|
if (await available(base)) {
|
|
@@ -145,44 +178,69 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
145
178
|
|
|
146
179
|
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean; current?: () => void } = {}): Promise<any> {
|
|
147
180
|
options.current?.();
|
|
148
|
-
assertLifecycleAvailable();
|
|
181
|
+
assertLifecycleAvailable(project.root);
|
|
149
182
|
const registry = new Registry();
|
|
150
183
|
try {
|
|
151
184
|
const current = registry.get(project.id);
|
|
152
185
|
if (!current || current.root !== project.root || current.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
186
|
+
const runtimeAccess = createProjectRuntimeAccess(current);
|
|
187
|
+
const admitRunningRuntime = () => {
|
|
188
|
+
const fresh = registry.get(project.id);
|
|
189
|
+
if (!fresh || fresh.root !== project.root || fresh.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
190
|
+
readProjectRuntimeForCaller(fresh, VERSION, runtimeAccess);
|
|
191
|
+
};
|
|
153
192
|
const before = await inspectProject(current);
|
|
154
193
|
options.current?.();
|
|
155
|
-
if (before.state === "running") return before.status;
|
|
194
|
+
if (before.state === "running") { admitRunningRuntime(); return before.status; }
|
|
156
195
|
if (before.state !== "stopped" && before.state !== "starting") throw new Error(before.error ?? before.state);
|
|
157
196
|
if (!existsSync(join(project.root, ".agenthub", "config.json"))) throw new Error("project is not initialized; run ahub init there first");
|
|
197
|
+
let launchedRuntime: ProjectRuntimeSelection | undefined;
|
|
158
198
|
if (before.state === "stopped") {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// operation ID must not make a later ordinary startup load a retired snapshot.
|
|
169
|
-
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
199
|
+
const launchEnv = { ...(options.env ?? process.env) };
|
|
200
|
+
// Expired operation ids must not make an ordinary startup load a retired snapshot.
|
|
201
|
+
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock(undefined, project.root)) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
202
|
+
registry.runtimeCaptureLocked(() => {
|
|
203
|
+
const claim = registry.get(project.id);
|
|
204
|
+
if (!claim || claim.root !== project.root || claim.stateDir !== project.stateDir) throw new Error("project registration changed; refresh before starting");
|
|
205
|
+
// A competing starter owns readiness now. Never replace its selection or create a second native start.
|
|
206
|
+
if (claim.instanceId && processLiveness(claim.pid, claim.pidSignature) !== "gone") return;
|
|
207
|
+
const runtime = startupRuntime(project, launchEnv, registry, runtimeAccess);
|
|
170
208
|
options.current?.();
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
209
|
+
assertLifecycleAvailable(project.root);
|
|
210
|
+
if (runtime.check() !== runtime.digest) throw new Error("staged startup runtime changed");
|
|
211
|
+
// The selected runtime's own preflight decides schema compatibility; an older invoking CLI cannot preflight for it.
|
|
212
|
+
recordProjectRuntime(project, runtime, null, registry, runtimeAccess);
|
|
213
|
+
launchedRuntime = runtime;
|
|
214
|
+
mkdirSync(project.stateDir, { recursive: true });
|
|
215
|
+
const marker = join(project.stateDir, "project.json");
|
|
216
|
+
const temporary = `${marker}.${randomUUID()}.tmp`;
|
|
217
|
+
writeFileSync(temporary, JSON.stringify({ root: project.root, projectId: project.id }), { mode: 0o600 });
|
|
218
|
+
renameSync(temporary, marker);
|
|
219
|
+
const log = openSync(join(project.stateDir, "hub.log"), "a");
|
|
220
|
+
try {
|
|
221
|
+
options.current?.();
|
|
222
|
+
const child = spawn(process.execPath, [join(runtime.root, "src/cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
|
|
223
|
+
...(options.unattended ? ["--unattended"] : [])], {
|
|
224
|
+
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
225
|
+
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
|
226
|
+
AGENTHUB_UNATTENDED: options.unattended ? "1" : "0" },
|
|
227
|
+
});
|
|
228
|
+
child.on("error", () => {}); // bounded readiness below reports failure with the project's log path
|
|
229
|
+
child.unref();
|
|
230
|
+
} finally { closeSync(log); }
|
|
231
|
+
});
|
|
180
232
|
}
|
|
181
233
|
const deadline = Date.now() + 12_000;
|
|
182
234
|
while (Date.now() < deadline) {
|
|
183
235
|
const observed = await inspectProject(registry.get(project.id) ?? project);
|
|
184
236
|
options.current?.();
|
|
185
|
-
if (observed.state === "running")
|
|
237
|
+
if (observed.state === "running") {
|
|
238
|
+
admitRunningRuntime();
|
|
239
|
+
if (launchedRuntime) confirmProjectRuntime(project, launchedRuntime, observed.status, registry, runtimeAccess);
|
|
240
|
+
// Unknown references defer maintenance; cache cleanup never changes a confirmed startup outcome.
|
|
241
|
+
try { scheduleRuntimeCapturePrune(); } catch { /* retain captures on unreadable inventory or cleanup failure */ }
|
|
242
|
+
return observed.status;
|
|
243
|
+
}
|
|
186
244
|
if (observed.state === "incompatible" || observed.state === "missing") throw new Error(observed.error ?? observed.state);
|
|
187
245
|
await Bun.sleep(100);
|
|
188
246
|
}
|
|
@@ -200,7 +258,7 @@ function claimHeld(id: string, instanceId: string): boolean {
|
|
|
200
258
|
}
|
|
201
259
|
|
|
202
260
|
export async function stopProject(project: Project, expectedInstance?: string): Promise<void> {
|
|
203
|
-
assertLifecycleAvailable();
|
|
261
|
+
assertLifecycleAvailable(project.root);
|
|
204
262
|
const inspection = await inspectProject(project);
|
|
205
263
|
if (inspection.state === "stopped") return;
|
|
206
264
|
if (inspection.state !== "running" && inspection.state !== "stopping") throw new Error(inspection.error ?? "hub ownership is not verified; not stopping it");
|
package/src/hub/manager.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { assertExecutableRuntimeRoot } from "./project-runtime-state.ts";
|
|
1
2
|
import { BACKLOG_SPLIT_SCREEN_TIMEOUT_MS } from "./planning.ts";
|
|
2
3
|
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
3
4
|
import { Database } from "bun:sqlite";
|
|
4
5
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
5
6
|
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-client.ts";
|
|
9
|
+
import { ControlClient, PROTOCOL, PROTOCOL_MIN, RECOVERY_SOURCE_PROTOCOLS, controlProtocolWindow } from "./control-client.ts";
|
|
9
10
|
import { Registry, type Project } from "./registry.ts";
|
|
10
|
-
import { hubHome } from "./project.ts";
|
|
11
|
+
import { hubHome, realPath } from "./project.ts";
|
|
11
12
|
import { startDashboard, dashboardView } from "./ui.ts";
|
|
12
13
|
import { projectChain } from "@staix/agent-mem";
|
|
13
14
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
@@ -22,10 +23,12 @@ export type Lifecycle = {
|
|
|
22
23
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
23
24
|
};
|
|
24
25
|
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
|
|
26
|
+
/** #608: runs after the dashboard's "start" has the hub ready, as the person's `ahub up` would, never for a live-test session. Its returned lines and any failure go to manager.log, one line each. Never part of a dashboard request. */
|
|
27
|
+
afterStart?: (project: Project) => Promise<string[]>;
|
|
25
28
|
/** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
|
|
26
29
|
forwardTimeoutMs?: (action: string, nominalMs: number) => number };
|
|
27
30
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
28
|
-
type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
|
|
31
|
+
type Manifest = { port: number; protocol: number; protocolMin?: number; protocolMax?: number; instanceId: string; pid: number; pidSignature?: string; runtimeRoot?: string };
|
|
29
32
|
const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
|
|
30
33
|
const CONTROL_TIMEOUT = 3000;
|
|
31
34
|
// A current CLI may authenticate the previous protocol-9 manager while
|
|
@@ -80,7 +83,7 @@ function removeManifest(home: string, instanceId: string) {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/** The manager's project listing and forwarding (#935): shared by `ahub ui --all` and the acon plugin, never widened for either. */
|
|
83
|
-
export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"] }) {
|
|
86
|
+
export function projectConsole(options: { registry: Registry; lifecycle: Lifecycle; home: string; stopping?: () => boolean; forwardTimeoutMs?: ManagerOptions["forwardTimeoutMs"]; afterStart?: ManagerOptions["afterStart"] }) {
|
|
84
87
|
const { registry, lifecycle, home } = options;
|
|
85
88
|
const aliases = new Map<string, { at: number; names: string[] }>();
|
|
86
89
|
const list = async () => {
|
|
@@ -102,7 +105,7 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
|
|
|
102
105
|
}));
|
|
103
106
|
return { ok: true, mode: "all", projects: rows };
|
|
104
107
|
};
|
|
105
|
-
const act = async (input: Record<string, unknown
|
|
108
|
+
const act = async (input: Record<string, unknown>, session?: { liveTest?: true }) => {
|
|
106
109
|
if (options.stopping?.()) return { ok: false, error: "manager is stopping" };
|
|
107
110
|
const project = typeof input.projectId === "string" ? registry.get(input.projectId) : undefined;
|
|
108
111
|
if (!project) return { ok: false, error: "unknown project" };
|
|
@@ -110,6 +113,14 @@ export function projectConsole(options: { registry: Registry; lifecycle: Lifecyc
|
|
|
110
113
|
try {
|
|
111
114
|
if (input.action === "start") {
|
|
112
115
|
const status = await bounded(lifecycle.startProject(project, { unattended: false, env: { ...scrubEnv(process.env), AGENTHUB_HOME: home } }), 20_000);
|
|
116
|
+
// The agents start in the background: the dashboard's answer is the hub's readiness, and a terminal that is slow to open must not hold it.
|
|
117
|
+
// A live-test dashboard session is an agent borrowing the person's console: it never opens the person's default agents. The session, not this process, carries that flag.
|
|
118
|
+
if (options.afterStart && session?.liveTest !== true) {
|
|
119
|
+
const stamp = (line: string) => `${new Date().toISOString()} default agents for ${project.id}: ${line.replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 600)}`;
|
|
120
|
+
void options.afterStart(project).then(
|
|
121
|
+
lines => { for (const line of lines) console.error(stamp(line)); },
|
|
122
|
+
error => { console.error(stamp(`not started: ${safeError(error)}`)); });
|
|
123
|
+
}
|
|
113
124
|
return { ok: true, projectId: project.id, instanceId: status.instanceId };
|
|
114
125
|
}
|
|
115
126
|
if (typeof input.instanceId !== "string" || !input.instanceId) return { ok: false, error: "expected daemon instance is required" };
|
|
@@ -181,31 +192,36 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
181
192
|
cleanup(); resolveStopped();
|
|
182
193
|
};
|
|
183
194
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return Response.json({ ok:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
195
|
+
const publicationRegistry = typeof registry.runtimeCaptureLocked === "function" && typeof registry.path === "string"
|
|
196
|
+
&& resolve(registry.path) === resolve(join(home, "registry.db")) ? registry : new Registry(join(home, "registry.db"));
|
|
197
|
+
try { publicationRegistry.runtimeCaptureLocked(() => {
|
|
198
|
+
assertExecutableRuntimeRoot(realPath(resolve(import.meta.dir, "../..")));
|
|
199
|
+
owner = claimOwner(f.owner, instanceId, pidSignature);
|
|
200
|
+
const { list, act } = projectConsole({ registry, lifecycle, home, stopping: () => stopping, forwardTimeoutMs: options.forwardTimeoutMs, ...(options.afterStart && !options.liveTest ? { afterStart: options.afterStart } : {}) });
|
|
201
|
+
dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
|
|
202
|
+
server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
|
|
203
|
+
async fetch(req) {
|
|
204
|
+
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
205
|
+
if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
|
|
206
|
+
if (req.method !== "POST") return new Response("POST required", { status: 405 });
|
|
207
|
+
const expected = req.headers.get("x-agenthub-instance");
|
|
208
|
+
if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
|
|
209
|
+
if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
|
|
210
|
+
const path = new URL(req.url).pathname;
|
|
211
|
+
if (path === "/open") {
|
|
212
|
+
const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
|
|
213
|
+
return Response.json({ ok: true, instanceId, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
|
|
214
|
+
}
|
|
215
|
+
if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
|
|
216
|
+
return new Response("not found", { status: 404 });
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
writeFileSync(f.token, token, { mode: 0o600 });
|
|
220
|
+
chmodSync(f.token, 0o600);
|
|
221
|
+
const temp = `${f.status}.${instanceId}.tmp`;
|
|
222
|
+
writeFileSync(temp, JSON.stringify({ port: server.port, protocol: PROTOCOL_MIN, protocolMin: PROTOCOL_MIN, protocolMax: PROTOCOL, instanceId, pid: process.pid, ...(pidSignature ? { pidSignature } : {}), runtimeRoot: realPath(resolve(import.meta.dir, "../..")) }), { mode: 0o600 });
|
|
223
|
+
renameSync(temp, f.status);
|
|
224
|
+
}); } finally { if (publicationRegistry !== registry) publicationRegistry.close(); }
|
|
209
225
|
// The manager outlives whoever opened it; a deleted AGENTHUB_HOME (a test suite's temp
|
|
210
226
|
// home, issue #56) must not leave it serving a registry that no longer exists.
|
|
211
227
|
orphanWatch = setInterval(() => {
|
|
@@ -222,7 +238,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
222
238
|
async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
|
|
223
239
|
const status = readManifest(home);
|
|
224
240
|
if (!status) throw new Error("manager manifest is unavailable");
|
|
225
|
-
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
241
|
+
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol) || !controlProtocolWindow(status)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
226
242
|
const token = readFileSync(files(home).token, "utf8").trim();
|
|
227
243
|
if (!token) throw new Error("manager token is unavailable");
|
|
228
244
|
const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
|
|
@@ -275,7 +291,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
275
291
|
export async function refreshManager(options: ManagerOptions = {}): Promise<string> {
|
|
276
292
|
const home = options.home ?? hubHome();
|
|
277
293
|
const manifest = readManifest(home);
|
|
278
|
-
if (manifest && manifest
|
|
294
|
+
if (manifest && (controlProtocolWindow(manifest)?.v ?? Infinity) < PROTOCOL && RECOVERY_SOURCE_PROTOCOLS.includes(manifest.protocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number])) {
|
|
279
295
|
await managerRequest(home, "/stop");
|
|
280
296
|
const deadline = Date.now() + CONTROL_TIMEOUT;
|
|
281
297
|
while (Date.now() < deadline && readManifest(home)) await Bun.sleep(25);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #613 (agent-hub#1048): the one source of truth for the Orca terminal environment. No imports, so every layer can read it
|
|
3
|
+
* (the launcher, the hub's terminal opener, the test preload and the gate's comment).
|
|
4
|
+
*
|
|
5
|
+
* The Orca launcher decides whether to open a terminal in the person's running Orca from these four names, so a process
|
|
6
|
+
* that inherits them reaches that Orca. A hub or a test run started from an Orca pane inherits them, so tests and the gate
|
|
7
|
+
* start with the whole `ORCA_` prefix removed and a test that wants one sets it itself.
|
|
8
|
+
*/
|
|
9
|
+
export const ORCA_LAUNCHER_ENV: readonly string[] = Object.freeze(["ORCA_CLI_COMMAND", "ORCA_DEV_REPO_ROOT", "ORCA_TERMINAL_HANDLE", "ORCA_WORKTREE_ID"]);
|
|
10
|
+
|
|
11
|
+
/** Every variable an Orca terminal exports: the launcher's four and the rest of the pane's environment. */
|
|
12
|
+
export const isOrcaEnvName = (name: string): boolean => name.startsWith("ORCA_");
|
|
13
|
+
|
|
14
|
+
/** A copy of `env` with every `ORCA_` variable removed. */
|
|
15
|
+
export function withoutOrcaEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
16
|
+
const out: NodeJS.ProcessEnv = { ...env };
|
|
17
|
+
for (const name of Object.keys(out)) if (isOrcaEnvName(name)) delete out[name];
|
|
18
|
+
return out;
|
|
19
|
+
}
|