@staix/agent-hub 0.20.0 → 0.21.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 +59 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +2 -2
- package/docs/agent-notes/daemon.md +15 -6
- package/docs/agent-notes/local-worker.md +4 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +17 -9
- package/docs/agent-notes/tests.md +9 -2
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +37 -4
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +565 -62
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +14 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +54 -10
- package/docs/security.md +396 -34
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1070 -22
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +15 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1016 -280
- package/src/adapters/acp.ts +79 -3
- package/src/adapters/channel-peer.ts +4 -0
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +60 -7
- package/src/adapters/codex-appserver.ts +77 -4
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +3 -9
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +40 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +42 -20
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +7 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1150 -171
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +12 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +6 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/tasks.ts +910 -110
- package/src/hub/workflow.ts +1 -1
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import { authorizeConductorOperation, conductorRecoveryBinding } from "./conductor-recovery.ts";
|
|
1
2
|
import { resolveAcceptedBinding } from "./recovery-guidance.ts";
|
|
2
3
|
import { claudeStateFile } from "../hub/claude-state.ts";
|
|
3
4
|
import { ADAPTERS, definitionOf } from "../adapters/registry.ts";
|
|
4
|
-
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
5
|
+
import { readInstanceConfig, readInstanceConfigLayers } from "../hub/instance-config.ts";
|
|
6
|
+
import { readAdapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
7
|
+
import { configRefusal } from "../hub/config-trust.ts";
|
|
8
|
+
import { validExternalInstanceBinding } from "../hub/external-instance.ts";
|
|
5
9
|
import { isDatabaseBusy, isDatabaseBusyReply, RecoveryDatabaseBusy } from "../hub/recovery-error.ts";
|
|
6
10
|
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
11
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
8
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
13
|
import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS, readControl } from "../hub/control-client.ts";
|
|
10
14
|
import { awaitStopped, inspectProject } from "../hub/lifecycle.ts";
|
|
@@ -78,8 +82,8 @@ export function restoredTerminalArgv(entrypoint: string, projectRoot: string, bi
|
|
|
78
82
|
|
|
79
83
|
const RECOVERY_CONTROL_TIMEOUT_MS = 30_000;
|
|
80
84
|
|
|
81
|
-
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL): Promise<any> {
|
|
82
|
-
const client = await ControlClient.connect(project.stateDir, { role: "console", projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
|
|
85
|
+
async function rpc(project: Project, message: Record<string, unknown>, protocol = PROTOCOL, conductorPeer?: string): Promise<any> {
|
|
86
|
+
const client = await ControlClient.connect(project.stateDir, { ...(conductorPeer ? { role: "tools" as const, peer: conductorPeer } : { role: "console" as const }), projectRoot: project.root, projectId: project.id }, RECOVERY_CONTROL_TIMEOUT_MS, protocol);
|
|
83
87
|
try {
|
|
84
88
|
const reply = await client.request(message, RECOVERY_CONTROL_TIMEOUT_MS);
|
|
85
89
|
if (reply.ok === false) {
|
|
@@ -142,10 +146,53 @@ export async function endPlannedPeer(planned: PlannedProject, peer: RecoveryPeer
|
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/** No imports or executable catalog reconstruction: unknown kinds are only possible module instances. */
|
|
150
|
+
function recoveryDeclarations(projectRoot: string) {
|
|
151
|
+
const layers = readInstanceConfigLayers(projectRoot);
|
|
152
|
+
const modules = readAdapterModuleDeclarations(projectRoot, layers).modules;
|
|
153
|
+
const local = layers.find(layer => layer.name === "config.local.json");
|
|
154
|
+
const raw = local && !configRefusal(projectRoot, local.name) ? local.raw : {};
|
|
155
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
156
|
+
const instances = raw.instances === undefined ? {} : raw.instances;
|
|
157
|
+
if (!object(instances) || Object.keys(instances).length > 64) throw new Error("recovery instance declaration inventory unavailable");
|
|
158
|
+
const configured = object(raw.adapters) ? Object.keys(raw.adapters) : [];
|
|
159
|
+
const moduleInstances = Object.entries(instances).filter(([, entry]) => !object(entry) || typeof entry.kind !== "string" || !definitionOf(entry.kind) && !configured.includes(entry.kind));
|
|
160
|
+
return { modules, instances, configured, moduleInstances };
|
|
161
|
+
}
|
|
162
|
+
const hasRecoveryFeatures = (features: unknown, required: readonly string[]): features is string[] => Array.isArray(features) && features.every(feature => typeof feature === "string") && required.every(feature => features.includes(feature));
|
|
163
|
+
const EXTERNAL_INSTANCE_FEATURES = ["external_adapter_modules", "peer_instances", "external_adapter_instances"] as const;
|
|
164
|
+
|
|
165
|
+
/** Includes offline declarations and retained identities, so a reused target cannot downgrade their meaning. */
|
|
166
|
+
export function instanceRecoveryTargetRefusal(projectRoot: string, features: unknown, retainedPeers: readonly RecoveryPeer[] = []): string | undefined {
|
|
167
|
+
const declarations = recoveryDeclarations(projectRoot);
|
|
168
|
+
if ((declarations.moduleInstances.length || retainedPeers.some(peer => Object.hasOwn(peer, "externalInstance"))) && !hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) {
|
|
169
|
+
return "target lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; module instances require a supporting target before recovery effects";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
145
173
|
/** Declared identities cannot be reinterpreted against an older running source's unbound peer rows. */
|
|
146
174
|
export function instanceRecoverySourceRefusal(projectRoot: string, features: unknown, observedPeers?: unknown): string | undefined {
|
|
175
|
+
const inventory = recoveryDeclarations(projectRoot);
|
|
176
|
+
if (inventory.moduleInstances.length) {
|
|
177
|
+
if (!hasRecoveryFeatures(features, EXTERNAL_INSTANCE_FEATURES)) return "source lacks external_adapter_instances or its external_adapter_modules/peer_instances prerequisites; restart with a supporting source before recovering module instances, including offline declarations";
|
|
178
|
+
const rows = observedPeers && typeof observedPeers === "object" && !Array.isArray(observedPeers) ? observedPeers as Record<string, unknown> : undefined;
|
|
179
|
+
for (const [id, declaration] of inventory.moduleInstances) {
|
|
180
|
+
const peer = rows?.[id] as RecoveryPeer | undefined;
|
|
181
|
+
const binding = peer?.externalInstance;
|
|
182
|
+
if (!declaration || typeof declaration !== "object" || !peer || !["idle", "busy", "paused", "offline"].includes(peer.state) || !validExternalInstanceBinding(binding, projectRoot, id) || binding.kind !== (declaration as { kind?: unknown }).kind ||
|
|
183
|
+
!inventory.modules.some(module => module.sha256 === binding.sha256 && relative(projectRoot, resolve(projectRoot, module.path)).split(sep).join("/") === binding.modulePath)) {
|
|
184
|
+
return "source external module instance binding unavailable or changed; a matching authenticated live startup catalog is required before recovery";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const configuredIds = [...inventory.configured, ...Object.entries(inventory.instances).filter(([, entry]) => entry && typeof entry === "object" && inventory.configured.includes((entry as { kind: string }).kind)).map(([id]) => id)];
|
|
188
|
+
if (configuredIds.length && !hasRecoveryFeatures(features, ["configured_acp"])) {
|
|
189
|
+
if (!rows || !Object.values(rows).every(peer => peer && typeof peer === "object" && typeof (peer as { state?: unknown }).state === "string") || Object.entries(rows).some(([id, peer]) => configuredIds.includes(id) && (peer as { state: string }).state !== "offline")) return "source lacks configured_acp; configured native identities require a supporting source before recovery";
|
|
190
|
+
}
|
|
191
|
+
// The authenticated source owns semantic admission; raw config and cached display metadata cannot reconstruct it.
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
147
194
|
const bound = readInstanceConfig(projectRoot);
|
|
148
|
-
if (bound.adapter_modules?.length && !(
|
|
195
|
+
if (bound.adapter_modules?.length && !hasRecoveryFeatures(features, ["external_adapter_modules"])) return "source lacks external_adapter_modules; restart with a supporting source before recovering module peers";
|
|
149
196
|
const configured = Object.keys(bound.adapters ?? {});
|
|
150
197
|
const configuredIds = [...configured, ...Object.entries(bound.instances ?? {}).filter(([, entry]) => configured.includes(entry.kind)).map(([id]) => id)];
|
|
151
198
|
if (configuredIds.length && !(Array.isArray(features) && features.every(feature => typeof feature === "string") && features.includes("configured_acp"))) {
|
|
@@ -163,12 +210,12 @@ export function instanceRecoverySourceRefusal(projectRoot: string, features: unk
|
|
|
163
210
|
return "source lacks peer_instances support and a declared instance of this project may be live; restart the source with a supporting agent-hub version before retrying recovery; for a first upgrade, end the extra native sessions first (a source whose declared instances are all offline proceeds without further steps); only if one cannot be ended, temporarily remove its declaration, upgrade the source, then declare it again; legacy peer identities were not reinterpreted";
|
|
164
211
|
}
|
|
165
212
|
|
|
166
|
-
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup"): Promise<Inspection> {
|
|
213
|
+
export async function inspectRecovery(project: Project, targetFeatures?: readonly string[], purpose?: "cleanup", conductorPeer?: string): Promise<Inspection> {
|
|
167
214
|
// #226: the claim is judged by the registry as it is now, never by the plan's frozen copy, which holds the source's
|
|
168
215
|
// pid from planning time (after a reboot another process may hold it). A registry that cannot be read throws: the
|
|
169
216
|
// caller reads that as not inspected, never as stopped.
|
|
170
217
|
const claim = registeredProjects().find((p) => p.id === project.id);
|
|
171
|
-
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS);
|
|
218
|
+
const base = await inspectProject({ ...project, instanceId: claim?.instanceId ?? null, pid: claim?.pid ?? null, pidSignature: claim?.pidSignature ?? null }, RECOVERY_CONTROL_TIMEOUT_MS, conductorPeer);
|
|
172
219
|
if (base.failure?.code === "database_busy") throw new RecoveryDatabaseBusy();
|
|
173
220
|
const control = readControl(project.stateDir);
|
|
174
221
|
const sourceProtocol = control?.protocol;
|
|
@@ -203,7 +250,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
203
250
|
};
|
|
204
251
|
if (!status && legacySupported && sourceProtocol !== PROTOCOL) {
|
|
205
252
|
try {
|
|
206
|
-
const readback = await rpc(project, { t: "status" }, sourceProtocol);
|
|
253
|
+
const readback = await rpc(project, { t: "status" }, sourceProtocol, conductorPeer);
|
|
207
254
|
status = readback.status;
|
|
208
255
|
} catch (error) {
|
|
209
256
|
if (isDatabaseBusy(error)) throw error;
|
|
@@ -218,7 +265,7 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
218
265
|
const instanceRefusal = purpose === "cleanup" ? undefined : instanceRecoverySourceRefusal(project.root, status.features, status.peers);
|
|
219
266
|
if (instanceRefusal) return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: [instanceRefusal] };
|
|
220
267
|
let response: any;
|
|
221
|
-
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL); }
|
|
268
|
+
try { response = await rpc(project, { t: "recovery", op: "inspect", expectedInstanceId: status.instanceId, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, sourceProtocol ?? PROTOCOL, conductorPeer); }
|
|
222
269
|
catch (error) {
|
|
223
270
|
if (isDatabaseBusy(error) || targetFeatures !== undefined) throw error;
|
|
224
271
|
// A stop can complete between authenticated status and the second read. Never infer stopped
|
|
@@ -227,16 +274,21 @@ export async function inspectRecovery(project: Project, targetFeatures?: readonl
|
|
|
227
274
|
peers: [], blockers: ["runtime changed during recovery inspection"] };
|
|
228
275
|
}
|
|
229
276
|
const peers = Object.values(response.recovery?.peers ?? {}) as any[];
|
|
277
|
+
if (peers.some(peer => peer && Object.hasOwn(peer, "externalInstance") && !validExternalInstanceBinding(peer.externalInstance, project.root, peer.id))) {
|
|
278
|
+
return { state: "incompatible", instanceId: status.instanceId, version: status.version, protocol: status.protocol, peers: [], blockers: ["source external module instance binding is invalid"] };
|
|
279
|
+
}
|
|
230
280
|
// #272: the hub's own words for what it waits on; shown to the person, never decided on.
|
|
231
281
|
const waiting = (Array.isArray(response.recovery?.blockers) ? response.recovery.blockers : []).filter((b: unknown): b is string => typeof b === "string").slice(0, 20).map((b: string) => b.slice(0, 200));
|
|
232
282
|
return { state: "running", instanceId: status.instanceId, version: status.version, protocol: status.protocol,
|
|
233
283
|
recovery: { operationId: response.recovery?.operationId, phase: response.recovery?.phase, ready: response.recovery?.ready, ...(waiting.length ? { waiting } : {}) }, peers: peers.map((peer) => ({ id: peer.id, state: peer.state,
|
|
284
|
+
...(peer.observation === undefined ? {} : { observation: peer.observation }),
|
|
285
|
+
...(peer.externalInstance === undefined ? {} : { externalInstance: peer.externalInstance }),
|
|
234
286
|
...(peer.threadId ? { threadId: peer.threadId } : {}), ...(peer.sessionId ? { sessionId: peer.sessionId } : {}),
|
|
235
287
|
...(typeof (peer.sessionFile ?? peer.launch?.sessionFile) === "string" ? { sessionFile: peer.sessionFile ?? peer.launch.sessionFile } : {}),
|
|
236
288
|
...(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: [] };
|
|
237
289
|
}
|
|
238
290
|
|
|
239
|
-
export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand): Promise<UpgradePlan> {
|
|
291
|
+
export async function makeUpgradePlan(kind: "restart" | "upgrade", version: string, selectedRoot?: string, run: RunCommand = runCommand, conductorPeer?: string): Promise<UpgradePlan> {
|
|
240
292
|
const projects = registeredProjects();
|
|
241
293
|
const sourceDigest = packageDigest(PACKAGE_ROOT);
|
|
242
294
|
const currentVersion = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
@@ -258,7 +310,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
258
310
|
for (const project of projects) {
|
|
259
311
|
if (kind === "restart" && project.root !== selectedRoot) continue;
|
|
260
312
|
let source: Inspection;
|
|
261
|
-
try { source = await inspectRecovery(project); }
|
|
313
|
+
try { source = await inspectRecovery(project, undefined, undefined, conductorPeer); }
|
|
262
314
|
catch { source = { state: "unavailable", peers: [], blockers: ["source recovery metadata could not be authenticated"] }; }
|
|
263
315
|
if (source.state === "stopped" || source.state === "missing") continue;
|
|
264
316
|
const blockers = [...source.blockers];
|
|
@@ -288,6 +340,7 @@ export async function makeUpgradePlan(kind: "restart" | "upgrade", version: stri
|
|
|
288
340
|
if (!launcher) {
|
|
289
341
|
unrecordedTerminals.push(peer.id);
|
|
290
342
|
delete peer.sessionId;
|
|
343
|
+
delete peer.args; // An unmanaged channel reconnects itself; its observed identity is not restoration data.
|
|
291
344
|
if (source.protocol === PROTOCOL) reconnectOnly.push(peer.id);
|
|
292
345
|
else blockers.push(`${peer.id}: unmanaged session (no live ahub claude launcher recorded) runs a protocol-${source.protocol} plugin and cannot reconnect to a protocol-${PROTOCOL} hub; next action: end that Claude session, or relaunch it with ahub claude in an Orca terminal, then make a new plan`);
|
|
293
346
|
continue;
|
|
@@ -364,7 +417,12 @@ export function preserveSource(plan: UpgradePlan, home = hubHome()): string {
|
|
|
364
417
|
return dest;
|
|
365
418
|
}
|
|
366
419
|
|
|
367
|
-
|
|
420
|
+
const unboundRecoveryInspection = inspectRecovery;
|
|
421
|
+
|
|
422
|
+
export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?: string, clock?: Pick<RecoveryDriver, "now" | "sleep">, conductorPeer?: string): RecoveryDriver {
|
|
423
|
+
let authorizedOperation: RecoveryOperation | undefined;
|
|
424
|
+
const recoveryCurrent = async () => { if (conductorPeer) { if (!authorizedOperation) throw new FinalRefusal("conductor recovery authority unavailable"); await authorizeConductorOperation(authorizedOperation, conductorPeer); } };
|
|
425
|
+
const inspectRecovery = (project: Project, features?: readonly string[], purpose?: "cleanup") => unboundRecoveryInspection(project, features, purpose, conductorPeer);
|
|
368
426
|
const now = clock?.now ?? (() => Date.now());
|
|
369
427
|
const sleep = clock?.sleep ?? ((ms: number) => Bun.sleep(ms));
|
|
370
428
|
/** `only`: one binding's native profile, for a command run once per profile (#450 I3: Claude instances may differ). */
|
|
@@ -376,6 +434,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
376
434
|
value.AGENTHUB_HOME = hubHome(); value.AGENTHUB_RECOVERY_OPERATION = op.id;
|
|
377
435
|
value.AGENTHUB_UNATTENDED = "0";
|
|
378
436
|
if (liveTestPeer) value.AGENTHUB_PEER_ID = liveTestPeer;
|
|
437
|
+
if (conductorPeer) value.AGENTHUB_PEER_ID = conductorPeer;
|
|
379
438
|
if (project) {
|
|
380
439
|
value.AGENTHUB_PROJECT_DIR = project.root; value.AGENTHUB_STATE_DIR = project.stateDir;
|
|
381
440
|
delete value.CODEX_HOME; delete value.CLAUDE_CONFIG_DIR;
|
|
@@ -390,14 +449,15 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
390
449
|
};
|
|
391
450
|
const command = async (op: RecoveryOperation, args: string[], project?: Project, only?: TerminalBinding) => {
|
|
392
451
|
const result = await run([process.execPath, join(op.targetRoot!, "src/cli/main.js"), ...(project ? ["--project", project.root] : []), ...(liveTestPeer ? ["--live-test"] : []), ...args],
|
|
393
|
-
{ env: env(op, project, only), cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
452
|
+
{ env: { ...env(op, project, only), ...(conductorPeer ? { AGENTHUB_RECOVERY_CONDUCTOR: conductorPeer, AGENTHUB_RECOVERY_CONTINUATION: op.id } : {}) }, cwd: project?.root ?? op.plan.projects[0]!.project.root, timeoutMs: 180_000 });
|
|
394
453
|
if (result.code !== 0) throw new Error(`target command ${args[0]} failed; inspect recovery state before retrying`);
|
|
395
454
|
return result;
|
|
396
455
|
};
|
|
397
|
-
const control = (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
|
|
456
|
+
const control = async (project: Project, op: string, id: string, instance: string, targetFeatures?: readonly string[]) => {
|
|
457
|
+
await recoveryCurrent();
|
|
398
458
|
const sourceProtocol = readControl(project.stateDir)?.protocol;
|
|
399
459
|
const protocol = sourceProtocol !== undefined && RECOVERY_SOURCE_PROTOCOLS.includes(sourceProtocol as (typeof RECOVERY_SOURCE_PROTOCOLS)[number]) ? sourceProtocol : PROTOCOL;
|
|
400
|
-
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }) }, protocol).then(() => {});
|
|
460
|
+
return rpc(project, { t: "recovery", op, operationId: id, expectedInstanceId: instance, ...(targetFeatures === undefined ? {} : { targetFeatures }), ...(conductorPeer && authorizedOperation ? { authority: conductorRecoveryBinding(authorizedOperation, project, conductorPeer) } : {}) }, protocol, conductorPeer).then(() => {});
|
|
401
461
|
};
|
|
402
462
|
const nativeFailure = (planned: PlannedProject, peer: string, reason: RecoveryNativeCause["reason"], message: string,
|
|
403
463
|
details: Omit<RecoveryNativeCause, "kind" | "reason" | "projectId" | "peer"> = {}) =>
|
|
@@ -510,6 +570,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
510
570
|
const idle = await waitForIdle(retired, 120_000, terminalOptions(run));
|
|
511
571
|
if (!idle.satisfied) throw new Error(`${peer}: terminal ${retired.handle} of the stopped target's session is not verified idle; finish or cancel its turn, or close that terminal by hand, first`);
|
|
512
572
|
progress.terminals[key] = "pending"; save();
|
|
573
|
+
await recoveryCurrent();
|
|
513
574
|
const result = await closeTerminal(retired, 0, terminalOptions(run));
|
|
514
575
|
if (result.manualRequired) throw new Error(`${peer}: closing terminal ${retired.handle} of the stopped target's session was not verified; manual-required`);
|
|
515
576
|
progress.terminals[key] = true; save();
|
|
@@ -566,6 +627,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
566
627
|
const decision = restoreTransition(waiting, { target: "running", launcher: launchState,
|
|
567
628
|
session: session && peer && ["idle", "busy", "paused"].includes(peer.state) ? changed ? "other" : "planned" : "none", allowOther: zeroTurn || expectedSession === null, binding });
|
|
568
629
|
if (decision.action === "settle" && session) {
|
|
630
|
+
await recoveryCurrent();
|
|
569
631
|
if (zeroTurn) waiveRecoveryPeers(planned.project.stateDir, op.id, { [binding.peer]: "zero-turn" });
|
|
570
632
|
return session;
|
|
571
633
|
}
|
|
@@ -609,6 +671,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
609
671
|
};
|
|
610
672
|
return {
|
|
611
673
|
...(liveTestPeer === undefined ? {} : { liveTestPeer }),
|
|
674
|
+
...(conductorPeer ? { conductorPeer, authorize: async (op: RecoveryOperation) => { await authorizeConductorOperation(op, conductorPeer); authorizedOperation = op; } } : {}),
|
|
612
675
|
now, sleep, inspect: inspectRecovery,
|
|
613
676
|
recordedSessionState: async (binding, kind) => nativeRecordedSessionState(binding, kind),
|
|
614
677
|
stage: async (op) => {
|
|
@@ -627,6 +690,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
627
690
|
return { ...target, ...(op.targetFeatures === undefined ? {} : { targetFeatures: [...op.targetFeatures] }) };
|
|
628
691
|
}
|
|
629
692
|
if (cached && op.targetFeatures !== undefined) {
|
|
693
|
+
for (const planned of op.plan.projects) {
|
|
694
|
+
const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, op.targetFeatures, planned.source.peers) : undefined;
|
|
695
|
+
if (refusal) throw new FinalRefusal(refusal);
|
|
696
|
+
}
|
|
630
697
|
if (!op.targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
631
698
|
if (!op.targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
632
699
|
if (!op.targetFeatures.includes("peer_instances") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).instances ?? {}).length > 0)) throw new FinalRefusal("target lacks peer_instances; declared project instances require a supporting target before recovery effects");
|
|
@@ -640,6 +707,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
640
707
|
if (!metadata || typeof metadata !== "object" || !Number.isInteger(metadata.protocol)) throw new Error(`the staged target's control protocol could not be read (probe exit ${probe.code}); staged package retained, runtimes unchanged; resume again once it can run`);
|
|
641
708
|
if (!Array.isArray(metadata.features) || !metadata.features.every((feature: unknown) => typeof feature === "string" && feature.length > 0)) throw new Error("the staged target features could not be read; staged package retained, runtimes unchanged");
|
|
642
709
|
const targetFeatures: readonly string[] = [...metadata.features];
|
|
710
|
+
for (const planned of op.plan.projects) {
|
|
711
|
+
const refusal = typeof planned.project?.root === "string" ? instanceRecoveryTargetRefusal(planned.project.root, targetFeatures, planned.source.peers) : undefined;
|
|
712
|
+
if (refusal) throw new FinalRefusal(refusal);
|
|
713
|
+
}
|
|
643
714
|
if (!targetFeatures.includes("external_adapter_modules") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && (readInstanceConfig(planned.project.root).adapter_modules?.length ?? 0) > 0)) throw new FinalRefusal("target lacks external_adapter_modules; module peers require a supporting target before recovery effects");
|
|
644
715
|
if (!targetFeatures.includes("configured_acp") && op.plan.projects.some(planned => typeof planned.project?.root === "string" && Object.keys(readInstanceConfig(planned.project.root).adapters ?? {}).length > 0)) throw new FinalRefusal("target lacks configured_acp; configured adapters require a supporting target before recovery effects");
|
|
645
716
|
if (metadata.protocol !== PROTOCOL) throw new FinalRefusal("target protocol requires a newer coordinator; staged package retained, runtimes unchanged");
|
|
@@ -705,6 +776,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
705
776
|
// the original peer roster and verified target features, including after a resume.
|
|
706
777
|
await control(planned.project, "prepare", op.id, planned.source.instanceId!, op.targetFeatures);
|
|
707
778
|
progress.terminals[key] = "pending"; save();
|
|
779
|
+
await recoveryCurrent();
|
|
708
780
|
const result = await closeTerminal(binding, 0, terminalOptions(run));
|
|
709
781
|
if (result.manualRequired) throw new Error(`${binding.peer}: terminal close not verified; manual-required`);
|
|
710
782
|
progress.terminals[key] = true; save();
|
|
@@ -716,6 +788,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
716
788
|
throw new Error("committed restart snapshot is missing or belongs to another operation");
|
|
717
789
|
}
|
|
718
790
|
const reconnect = op.plan.projects.find((planned) => planned.project.id === p.id)?.reconnectOnly ?? [];
|
|
791
|
+
await recoveryCurrent();
|
|
719
792
|
if (reconnect.length) waiveRecoveryPeers(p.stateDir, op.id, Object.fromEntries(reconnect.map((peer) => [peer, "reconnect-only"])));
|
|
720
793
|
await command(op, ["up"], p);
|
|
721
794
|
},
|
|
@@ -727,6 +800,10 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
727
800
|
const current = live.peers.find((p) => p.id === peer.id);
|
|
728
801
|
if (current && current.state !== "offline") continue;
|
|
729
802
|
const adapter = projectDefinition(peer.id, planned.project.root)!;
|
|
803
|
+
if (conductorPeer) {
|
|
804
|
+
await rpc(planned.project, { t: "recovery_restore", peer: peer.id, authority: conductorRecoveryBinding(op, planned.project, conductorPeer) }, PROTOCOL, conductorPeer);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
730
807
|
const args = [adapter.cli.command, ...(peer.id !== adapter.kind ? ["--as", peer.id] : [])];
|
|
731
808
|
const refusal = adapter.session.recoveryRefusal?.(peer, "restore");
|
|
732
809
|
if (refusal) throw new Error(refusal);
|
|
@@ -803,6 +880,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
803
880
|
// #21: a Claude session that never got a first turn has no transcript, so `claude --resume S` can never
|
|
804
881
|
// succeed and accepting its fresh attach loses nothing. Otherwise only the planned id, or a new session
|
|
805
882
|
// the operator or the plan accepted, counts as restored.
|
|
883
|
+
await recoveryCurrent();
|
|
806
884
|
if (attached !== original.sessionId && !fresh() && !(!recordedOnly && nativeZeroTurn(planned.project.stateDir, original, "attach"))) {
|
|
807
885
|
throw new Error(`${original.peer}: terminal creation outcome is uncertain: session ${attached} is attached instead of ${original.sessionId}; end that ${original.peer} session and close its terminal first`);
|
|
808
886
|
}
|
|
@@ -817,6 +895,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
817
895
|
// Any other accepted session needs the target to waive the saved id, or release would wait for it forever;
|
|
818
896
|
// a launch writes the waiver first, but this must not rely on that.
|
|
819
897
|
// The planned session needs no waiver: the target's snapshot already expects it.
|
|
898
|
+
await recoveryCurrent();
|
|
820
899
|
if (attached !== original.sessionId && attached !== plannedBinding.sessionId) waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
821
900
|
if (repair) repair.state = "attempted"; // a verified spontaneous reattachment also closes this repair allowance
|
|
822
901
|
// The person accepted one loss, not a permanent exemption for later repairs of the replacement.
|
|
@@ -881,6 +960,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
881
960
|
if (fresh()) {
|
|
882
961
|
if (!targetReadsWaivers(op)) throw new Error(`${original.peer}: target ${op.plan.version} predates recovery waivers, so it cannot accept a new session`);
|
|
883
962
|
// The restored daemon must accept the new session instead of the recorded one.
|
|
963
|
+
await recoveryCurrent();
|
|
884
964
|
waiveRecoveryPeers(planned.project.stateDir, op.id, { [original.peer]: waiver() });
|
|
885
965
|
}
|
|
886
966
|
if (repair) {
|
|
@@ -894,6 +974,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
894
974
|
command: ["env", ...Object.entries(assignments).map(([k, v]) => `${k}=${v}`), ...argv].map(shellQuote).join(" ") };
|
|
895
975
|
const binding = { ...original, launch, launchMetadata: launch };
|
|
896
976
|
progress.terminals[key] = { state: "pending" } satisfies RestoreReceipt; save();
|
|
977
|
+
await recoveryCurrent();
|
|
897
978
|
const restored = await createTerminal(binding, { ...terminalOptions(run), stateDir: planned.project.stateDir, instanceId: progress.instanceId }, undefined, fresh());
|
|
898
979
|
const exited = restored.blockers.find((b) => b.code === "launcher-exited");
|
|
899
980
|
if (exited) {
|
|
@@ -970,6 +1051,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
970
1051
|
if (!configured.size) await command(op, ["setup", "--yes"]);
|
|
971
1052
|
},
|
|
972
1053
|
installGlobal: async (op) => {
|
|
1054
|
+
await recoveryCurrent();
|
|
973
1055
|
const result = await run([process.execPath, "add", "-g", `@staix/agent-hub@${op.plan.version}`, "--ignore-scripts"], { env: env(op), timeoutMs: 180_000 });
|
|
974
1056
|
if (result.code !== 0) throw new Error("global installation failed; project runtimes use the retained staged package");
|
|
975
1057
|
const readback = await run(["ahub", "--version"], { env: env(op) });
|
|
@@ -1005,7 +1087,7 @@ export function makeRecoveryDriver(run: RunCommand = runCommand, liveTestPeer?:
|
|
|
1005
1087
|
} catch { return { continuity: "unavailable" }; } // optional metadata must not wedge stop-and-archive
|
|
1006
1088
|
}
|
|
1007
1089
|
},
|
|
1008
|
-
refreshManager: async (op) => { await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
1090
|
+
refreshManager: async (op) => { if (conductorPeer && op.plan.kind === "restart") return; await refreshManager({ home: hubHome(), cli: join(op.targetRoot!, "src/cli/main.ts") }); },
|
|
1009
1091
|
verify: async (planned, progress, op, save) => {
|
|
1010
1092
|
const live = await inspectRecovery(planned.project);
|
|
1011
1093
|
if (live.instanceId !== progress.instanceId || live.version !== op.plan.version || live.recovery?.operationId !== op.id) throw new Error("target identity verification failed");
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { nativeRestoreGroup } from "../adapters/definitions/native-identity-defaults.ts";
|
|
2
2
|
import { definitionOf } from "../adapters/registry.ts";
|
|
3
3
|
import { readInstanceConfig } from "../hub/instance-config.ts";
|
|
4
|
+
import { validExternalInstanceBinding, type ExternalInstanceBinding } from "../hub/external-instance.ts";
|
|
4
5
|
import { LIVE_TEST_PERSON_REFUSAL } from "./identity.ts";
|
|
5
6
|
import { checkedSessionPath } from "./recovery-session-file.ts";
|
|
6
7
|
import { canChooseFresh, decodeRestoreReceipt, restoreReceiptStatus } from "./upgrade-restore-state.ts";
|
|
@@ -11,7 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { REGISTRY_DB_BUSY_TIMEOUT_MS, PROJECT_COLUMNS, projectFromRow, type Project } from "../hub/registry.ts";
|
|
13
14
|
import { hubHome } from "../hub/project.ts";
|
|
14
|
-
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
15
|
+
import { acquireRecoveryLock, claimRunner, coordinatorCurrent, operationIdPattern, readOperation, recoveryCommand, recoveryLock, releaseRecoveryLock, writeOperation } from "../hub/recovery-store.ts";
|
|
15
16
|
import { resolveAcceptedBinding, piArchiveExit, recordedPeerGuidance, type RecoveryNativePeer } from "./recovery-guidance.ts";
|
|
16
17
|
import type { TerminalBinding } from "./terminal-recovery.ts";
|
|
17
18
|
import { RecoveryPresentationError, type RecoveryPresentationCause, type RecoveryWaitTiming } from "./upgrade-presentation-data.ts";
|
|
@@ -25,6 +26,10 @@ const projectDefinition = (peer: string, root?: string) => {
|
|
|
25
26
|
|
|
26
27
|
export interface RecoveryPeer {
|
|
27
28
|
id: string;
|
|
29
|
+
/** Read-only source observation; never consumed as restoration arguments. */
|
|
30
|
+
observation?: { kind: string; attached: boolean; sessionDigest?: string };
|
|
31
|
+
/** Captured stable module/instance identity; never a portable current-owner proof. */
|
|
32
|
+
externalInstance?: ExternalInstanceBinding;
|
|
28
33
|
state: string;
|
|
29
34
|
threadId?: string;
|
|
30
35
|
sessionId?: string;
|
|
@@ -126,6 +131,8 @@ export interface RecoveryOperation {
|
|
|
126
131
|
sourceRoot: string;
|
|
127
132
|
/** Ids-only initiating and subsequent delegated invocations; retained across coordinator replacement. */
|
|
128
133
|
delegatedAudits?: RecoveryDelegationAudit[];
|
|
134
|
+
/** Source template bytes only when the project has no routing.toml; integrity is sealed in the private policy digest. */
|
|
135
|
+
conductorRoutingDefaults?: Record<string, string>;
|
|
129
136
|
targetRoot?: string;
|
|
130
137
|
targetDigest?: string;
|
|
131
138
|
/** Verified capability evidence bound to targetRoot/targetDigest; absence is legacy unknown, not an empty list. */
|
|
@@ -409,12 +416,13 @@ export function planFingerprint(plan: Omit<UpgradePlan, "fingerprint">): string
|
|
|
409
416
|
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
|
410
417
|
}
|
|
411
418
|
|
|
412
|
-
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation): RecoveryOperation {
|
|
419
|
+
export function createOperation(plan: UpgradePlan, sourceRoot: string, home = hubHome(), delegation?: RecoveryDelegation, operationId: string = randomUUID()): RecoveryOperation {
|
|
420
|
+
if (!operationIdPattern.test(operationId)) throw new Error("invalid recovery operation ID");
|
|
413
421
|
if (plan.blockers.length || plan.projects.some((p) => p.blockers.length)) throw new Error("upgrade plan has blockers; no runtimes were changed");
|
|
414
422
|
if (!plan.projects.length) throw new Error("no running projects to recover");
|
|
415
423
|
const { fingerprint, ...body } = plan;
|
|
416
424
|
if (planFingerprint(body) !== fingerprint) throw new Error("upgrade plan fingerprint is invalid");
|
|
417
|
-
const id =
|
|
425
|
+
const id = operationId;
|
|
418
426
|
const op: RecoveryOperation = {
|
|
419
427
|
schema: 1, id, plan, sourceRoot, createdAt: Date.now(), updatedAt: Date.now(), phase: "pending", step: "stage",
|
|
420
428
|
projects: plan.projects.map((p) => ({ id: p.project.id, phase: "pending", terminals: {} })),
|
|
@@ -487,6 +495,10 @@ function validateReceipt(id: string, op: RecoveryOperation, historical = false):
|
|
|
487
495
|
op.projects.some((p, i) => p.id !== op.plan.projects[i]?.project.id)) throw new Error("reviewed operation plan or project scope changed");
|
|
488
496
|
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
489
497
|
for (const [index, progress] of op.projects.entries()) {
|
|
498
|
+
const planned = op.plan.projects[index]!;
|
|
499
|
+
for (const peer of planned.source.peers) {
|
|
500
|
+
if (Object.hasOwn(peer, "externalInstance") && !validExternalInstanceBinding(peer.externalInstance, planned.project.root, peer.id)) throw new Error("invalid retained external module instance binding");
|
|
501
|
+
}
|
|
490
502
|
if (progress.repairedSessions !== undefined) {
|
|
491
503
|
if (!object(progress.repairedSessions)) throw new Error("invalid repaired session provenance");
|
|
492
504
|
for (const [peer, protectedSession] of Object.entries(progress.repairedSessions)) {
|
|
@@ -559,6 +571,45 @@ export interface RecoveryDriver {
|
|
|
559
571
|
now(): number;
|
|
560
572
|
/** Trusted per-invocation delegation, supplied only by the CLI's identity preflight. */
|
|
561
573
|
readonly liveTestPeer?: string;
|
|
574
|
+
/** Trusted actor from the live conductor preflight; receipt audit rows confer no authority. */
|
|
575
|
+
readonly conductorPeer?: string;
|
|
576
|
+
/** Host-only current authority proof for this exact operation and every affected project. */
|
|
577
|
+
authorize?(op: RecoveryOperation, phase: "run" | "abort" | "effect"): Promise<void>;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function authorizeRecoveryDriver(driver: RecoveryDriver, op: RecoveryOperation, phase: "run" | "abort" | "effect"): Promise<void> {
|
|
581
|
+
if (driver.conductorPeer === undefined) return;
|
|
582
|
+
if (!validRecoveryDelegation({ peer: driver.conductorPeer, command: "recovery-run" }) || typeof driver.authorize !== "function") throw new Error("conductor recovery authorization unavailable");
|
|
583
|
+
await driver.authorize(op, phase);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Closed effect ports only. Inspection stays read-only; person drivers keep their existing behavior. */
|
|
587
|
+
export function guardedRecoveryDriver(driver: RecoveryDriver, operation: () => RecoveryOperation): RecoveryDriver {
|
|
588
|
+
if (driver.conductorPeer === undefined) return driver;
|
|
589
|
+
const guard = <A extends unknown[], R>(action: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
|
|
590
|
+
await authorizeRecoveryDriver(driver, operation(), "effect");
|
|
591
|
+
try { return await action(...args); }
|
|
592
|
+
finally { await authorizeRecoveryDriver(driver, operation(), "effect"); }
|
|
593
|
+
};
|
|
594
|
+
return {
|
|
595
|
+
...driver,
|
|
596
|
+
stage: guard((...args: Parameters<RecoveryDriver["stage"]>) => driver.stage(...args)),
|
|
597
|
+
prepare: guard((...args: Parameters<RecoveryDriver["prepare"]>) => driver.prepare(...args)),
|
|
598
|
+
abort: guard((...args: Parameters<RecoveryDriver["abort"]>) => driver.abort(...args)),
|
|
599
|
+
commit: guard((...args: Parameters<RecoveryDriver["commit"]>) => driver.commit(...args)),
|
|
600
|
+
closeTerminals: guard((...args: Parameters<RecoveryDriver["closeTerminals"]>) => driver.closeTerminals(...args)),
|
|
601
|
+
start: guard((...args: Parameters<RecoveryDriver["start"]>) => driver.start(...args)),
|
|
602
|
+
restore: guard((...args: Parameters<RecoveryDriver["restore"]>) => driver.restore(...args)),
|
|
603
|
+
installPlugin: guard((...args: Parameters<RecoveryDriver["installPlugin"]>) => driver.installPlugin(...args)),
|
|
604
|
+
installGlobal: guard((...args: Parameters<RecoveryDriver["installGlobal"]>) => driver.installGlobal(...args)),
|
|
605
|
+
...(driver.refreshManager ? { refreshManager: guard((op: RecoveryOperation) => driver.refreshManager!(op)) } : {}),
|
|
606
|
+
release: guard((...args: Parameters<RecoveryDriver["release"]>) => driver.release(...args)),
|
|
607
|
+
verify: guard((...args: Parameters<RecoveryDriver["verify"]>) => driver.verify(...args)),
|
|
608
|
+
stopAndArchive: guard((...args: Parameters<RecoveryDriver["stopAndArchive"]>) => driver.stopAndArchive(...args)),
|
|
609
|
+
inspect: (...args) => driver.inspect(...args),
|
|
610
|
+
...(driver.recordedSessionState ? { recordedSessionState: (binding: TerminalBinding, kind: "rollout" | "transcript") => driver.recordedSessionState!(binding, kind) } : {}),
|
|
611
|
+
sleep: ms => driver.sleep(ms), now: () => driver.now(),
|
|
612
|
+
};
|
|
562
613
|
}
|
|
563
614
|
|
|
564
615
|
/** Resumable state machine. Driver actions are preceded by receipts and followed by live readback. */
|
|
@@ -567,6 +618,7 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
567
618
|
// mutable phase. A concurrent runner may have completed the operation meanwhile.
|
|
568
619
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
569
620
|
validateReceipt(id, op);
|
|
621
|
+
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
|
|
570
622
|
acquireRecoveryLock(id, home);
|
|
571
623
|
const releaseRunner = claimRunner(id, home);
|
|
572
624
|
try {
|
|
@@ -577,11 +629,13 @@ export async function runRecovery(id: string, driver: RecoveryDriver, home = hub
|
|
|
577
629
|
releaseRunner();
|
|
578
630
|
return op;
|
|
579
631
|
}
|
|
580
|
-
if (driver.
|
|
581
|
-
|
|
632
|
+
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "run");
|
|
633
|
+
if (driver.conductorPeer ?? driver.liveTestPeer) {
|
|
634
|
+
appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery-run" }, driver.now());
|
|
582
635
|
writeOperation(id, op, home);
|
|
583
636
|
}
|
|
584
637
|
} catch (error) { releaseRunner(); throw error; }
|
|
638
|
+
driver = guardedRecoveryDriver(driver, () => op);
|
|
585
639
|
let targetFeatures: readonly string[] = [];
|
|
586
640
|
const save = () => { op.updatedAt = driver.now(); writeOperation(id, op, home); };
|
|
587
641
|
const step = (value: string) => { op.step = value; save(); };
|
|
@@ -867,6 +921,7 @@ export function publicOperation(op: RecoveryOperation, runnerPid?: number | "unk
|
|
|
867
921
|
* release, installation or completion. A later normal resume owns those steps.
|
|
868
922
|
*/
|
|
869
923
|
export async function reattachRecovery(id: string, peer: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
|
|
924
|
+
if (driver.conductorPeer !== undefined) throw new Error("recovery reattachment is person-only");
|
|
870
925
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
871
926
|
validateReceipt(id, op);
|
|
872
927
|
const selection = () => {
|
|
@@ -929,6 +984,7 @@ export async function reattachRecovery(id: string, peer: string, driver: Recover
|
|
|
929
984
|
export async function abortRecovery(id: string, driver: RecoveryDriver, home = hubHome()): Promise<void> {
|
|
930
985
|
let op = readOperation<RecoveryOperation>(id, home);
|
|
931
986
|
validateReceipt(id, op, true);
|
|
987
|
+
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
932
988
|
acquireRecoveryLock(id, home);
|
|
933
989
|
const releaseRunner = claimRunner(id, home);
|
|
934
990
|
try {
|
|
@@ -940,10 +996,12 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
940
996
|
if (recoveryLock(home) === id) releaseRecoveryLock(id, home);
|
|
941
997
|
return;
|
|
942
998
|
}
|
|
943
|
-
if (driver.
|
|
944
|
-
|
|
999
|
+
if (driver.conductorPeer !== undefined) await authorizeRecoveryDriver(driver, op, "abort");
|
|
1000
|
+
if (driver.conductorPeer ?? driver.liveTestPeer) {
|
|
1001
|
+
appendRecoveryDelegation(op, { peer: (driver.conductorPeer ?? driver.liveTestPeer)!, command: "recovery abort" }, driver.now());
|
|
945
1002
|
writeOperation(id, op, home);
|
|
946
1003
|
}
|
|
1004
|
+
driver = guardedRecoveryDriver(driver, () => op);
|
|
947
1005
|
if (abandoned(op)) writeOperation(id, op, home);
|
|
948
1006
|
const { fingerprint, ...body } = op.plan;
|
|
949
1007
|
if (fingerprint !== planFingerprint(body)) throw new Error("operation plan changed");
|
|
@@ -971,6 +1029,7 @@ export async function abortRecovery(id: string, driver: RecoveryDriver, home = h
|
|
|
971
1029
|
* every runtime this operation held or started is verified released or stopped.
|
|
972
1030
|
*/
|
|
973
1031
|
export async function disposeRecovery(id: string, choice: { fresh: string } | { stop: true }, reason: string, driver: RecoveryDriver, home = hubHome()): Promise<RecoveryOperation> {
|
|
1032
|
+
if (driver.conductorPeer !== undefined) throw new Error("recovery disposition is person-only");
|
|
974
1033
|
let op: RecoveryOperation;
|
|
975
1034
|
try { op = readOperation<RecoveryOperation>(id, home); validateReceipt(id, op, "stop" in choice); }
|
|
976
1035
|
catch (error) { if (driver.liveTestPeer && "fresh" in choice) throw new Error(LIVE_TEST_PERSON_REFUSAL); throw error; }
|