@staix/agent-hub 0.19.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- 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 +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- 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 +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -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 +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- 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 +8 -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 +1228 -242
- 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 +46 -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 +7 -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/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- 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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** Host-only conductor continuity. Public operation JSON, actor labels and audits grant nothing.
|
|
2
|
+
* The pin is this registered project's canonical host-owned private key, never a key carried
|
|
3
|
+
* by a record. Like existing host state, it does not resist a same-OS-user process able to
|
|
4
|
+
* replace both key and record. Fresh host policy/privacy proof remains mandatory per effect.
|
|
5
|
+
*/
|
|
6
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { closeSync, constants, fstatSync, linkSync, lstatSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join } from "node:path";
|
|
9
|
+
import { realPath } from "./project.ts";
|
|
10
|
+
|
|
11
|
+
export interface ConductorRecoveryBinding {
|
|
12
|
+
schema: 1; operationId: string; peer: string; kind: "restart" | "upgrade";
|
|
13
|
+
projectId: string; projectRoot: string; stateDir: string; sourceInstanceId: string;
|
|
14
|
+
targetRoot: string; targetDigest: string; planFingerprint: string; policyDigest: string; boardDigest: string;
|
|
15
|
+
}
|
|
16
|
+
export interface VerifiedRecoveryAuthority { readonly binding: Readonly<ConductorRecoveryBinding> }
|
|
17
|
+
export class RecoveryAuthorityError extends Error {
|
|
18
|
+
readonly code = "AHUB_RECOVERY_AUTHORITY";
|
|
19
|
+
constructor() { super("conductor recovery authority unavailable; the person runs it in ahub console or a terminal"); this.name = "RecoveryAuthorityError"; }
|
|
20
|
+
}
|
|
21
|
+
type Current = (binding: Readonly<ConductorRecoveryBinding>) => boolean;
|
|
22
|
+
type PrivateRecord = { binding: Readonly<ConductorRecoveryBinding>; retired: boolean; seal: string };
|
|
23
|
+
type FileRead = { bytes: Buffer; identity: string };
|
|
24
|
+
type Grant = { stateDir: string; directory: string; key: string; record: string; current: Current };
|
|
25
|
+
const grants = new WeakMap<VerifiedRecoveryAuthority, Grant>();
|
|
26
|
+
const KEYS: readonly (keyof ConductorRecoveryBinding)[] = ["schema", "operationId", "peer", "kind", "projectId", "projectRoot", "stateDir", "sourceInstanceId", "targetRoot", "targetDigest", "planFingerprint", "policyDigest", "boardDigest"];
|
|
27
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
28
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
29
|
+
const MAX_RECORD = 16 * 1024;
|
|
30
|
+
function refuse(): never { throw new RecoveryAuthorityError(); }
|
|
31
|
+
function guarded<T>(work: () => T): T { try { return work(); } catch { return refuse(); } }
|
|
32
|
+
function object(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) refuse();
|
|
34
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
35
|
+
if (Reflect.ownKeys(fields).length !== keys.length || keys.some(key => !fields[key] || !Object.hasOwn(fields[key]!, "value"))) refuse();
|
|
36
|
+
return value as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
function binding(value: unknown): Readonly<ConductorRecoveryBinding> {
|
|
39
|
+
const row = object(value, KEYS);
|
|
40
|
+
if (row.schema !== 1 || typeof row.operationId !== "string" || !UUID.test(row.operationId) || typeof row.peer !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(row.peer)
|
|
41
|
+
|| ["user", "hub", "digest"].includes(row.peer) || row.kind !== "restart" && row.kind !== "upgrade") refuse();
|
|
42
|
+
for (const key of ["projectId", "sourceInstanceId"] as const) if (typeof row[key] !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(row[key])) refuse();
|
|
43
|
+
for (const key of ["projectRoot", "stateDir", "targetRoot"] as const) {
|
|
44
|
+
const value = row[key]; if (typeof value !== "string" || value.length > 4096 || !isAbsolute(value) || /[\p{Cc}]/u.test(value) || realPath(value) !== value) refuse();
|
|
45
|
+
}
|
|
46
|
+
for (const key of ["targetDigest", "planFingerprint", "policyDigest", "boardDigest"] as const) if (typeof row[key] !== "string" || !HASH.test(row[key])) refuse();
|
|
47
|
+
return Object.freeze(Object.fromEntries(KEYS.map(key => [key, row[key]])) as unknown as ConductorRecoveryBinding);
|
|
48
|
+
}
|
|
49
|
+
function identity(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
|
|
50
|
+
return JSON.stringify([stat.dev, stat.ino, stat.mode, stat.uid, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs]);
|
|
51
|
+
}
|
|
52
|
+
function directory(stateDir: string): string {
|
|
53
|
+
if (!isAbsolute(stateDir) || realPath(stateDir) !== stateDir) refuse();
|
|
54
|
+
const stat = lstatSync(stateDir);
|
|
55
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !process.getuid || stat.uid !== process.getuid() || (stat.mode & 0o022) !== 0) refuse();
|
|
56
|
+
// Directory contents change during publication; pin its inode, owner and permission, not mtime.
|
|
57
|
+
return JSON.stringify([stat.dev, stat.ino, stat.uid, stat.mode]);
|
|
58
|
+
}
|
|
59
|
+
function readPrivate(file: string, maximum: number, exact?: number): FileRead {
|
|
60
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
61
|
+
try {
|
|
62
|
+
const before = fstatSync(fd);
|
|
63
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o777) !== 0o600 || !process.getuid || before.uid !== process.getuid()
|
|
64
|
+
|| before.size < 1 || before.size > maximum || exact !== undefined && before.size !== exact) refuse();
|
|
65
|
+
const bytes = Buffer.alloc(before.size + 1); let size = 0;
|
|
66
|
+
while (size < bytes.length) { const n = readSync(fd, bytes, size, bytes.length - size, null); if (!n) break; size += n; }
|
|
67
|
+
if (size !== before.size || identity(fstatSync(fd)) !== identity(before) || identity(lstatSync(file)) !== identity(before) || realPath(file) !== file) refuse();
|
|
68
|
+
return { bytes: bytes.subarray(0, size), identity: identity(before) };
|
|
69
|
+
} finally { closeSync(fd); }
|
|
70
|
+
}
|
|
71
|
+
const keyPath = (stateDir: string) => join(stateDir, "conductor-recovery.key");
|
|
72
|
+
const recordPath = (stateDir: string, operationId: string) => join(stateDir, `conductor-recovery.${operationId}.json`);
|
|
73
|
+
function seal(key: Buffer, value: Omit<PrivateRecord, "seal">): string {
|
|
74
|
+
return createHmac("sha256", key).update("agent-hub/conductor-recovery/v1\n").update(JSON.stringify(value)).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
function encodeRecord(key: Buffer, value: Omit<PrivateRecord, "seal">): Buffer {
|
|
77
|
+
const bytes = Buffer.from(JSON.stringify({ ...value, seal: seal(key, value) })); if (bytes.length > MAX_RECORD) refuse(); return bytes;
|
|
78
|
+
}
|
|
79
|
+
function checkRecord(stateDir: string, expected: Readonly<ConductorRecoveryBinding>): { key: FileRead; record: FileRead; value: PrivateRecord } {
|
|
80
|
+
const key = readPrivate(keyPath(stateDir), 32, 32), record = readPrivate(recordPath(stateDir, expected.operationId), MAX_RECORD);
|
|
81
|
+
const row = object(JSON.parse(record.bytes.toString("utf8")), ["binding", "retired", "seal"]), bound = binding(row.binding);
|
|
82
|
+
if (typeof row.retired !== "boolean" || typeof row.seal !== "string" || !HASH.test(row.seal) || JSON.stringify(bound) !== JSON.stringify(expected)) refuse();
|
|
83
|
+
const computed = seal(key.bytes, { binding: bound, retired: row.retired });
|
|
84
|
+
if (!timingSafeEqual(Buffer.from(row.seal, "hex"), Buffer.from(computed, "hex"))) refuse();
|
|
85
|
+
return { key, record, value: { binding: bound, retired: row.retired, seal: row.seal } };
|
|
86
|
+
}
|
|
87
|
+
function fresh(current: Current, value: Readonly<ConductorRecoveryBinding>): void { if (current(value) !== true) refuse(); }
|
|
88
|
+
function stillPrivate(file: string, stamp: string): void { if (identity(lstatSync(file)) !== stamp || realPath(file) !== file) refuse(); }
|
|
89
|
+
/** Publish a whole private file, never an empty capability visible to another reader. */
|
|
90
|
+
function publishNew(stateDir: string, file: string, bytes: Buffer): void {
|
|
91
|
+
const temporary = join(stateDir, `.conductor-recovery.${randomUUID()}.tmp`);
|
|
92
|
+
try { writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 }); linkSync(temporary, file); }
|
|
93
|
+
finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Call only at the authenticated source's current-conductor boundary. A verifier never creates a key. */
|
|
97
|
+
export function issueConductorRecoveryAuthority(stateDir: string, input: ConductorRecoveryBinding, current: () => boolean): void {
|
|
98
|
+
return guarded(() => {
|
|
99
|
+
const value = binding(input), pin = directory(stateDir); if (value.stateDir !== stateDir) refuse(); fresh(current, value);
|
|
100
|
+
try { readPrivate(keyPath(stateDir), 32, 32); }
|
|
101
|
+
catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
103
|
+
fresh(current, value); publishNew(stateDir, keyPath(stateDir), randomBytes(32));
|
|
104
|
+
}
|
|
105
|
+
const key = readPrivate(keyPath(stateDir), 32, 32);
|
|
106
|
+
try {
|
|
107
|
+
const existing = checkRecord(stateDir, value); if (existing.value.retired) refuse();
|
|
108
|
+
fresh(current, value); stillPrivate(keyPath(stateDir), key.identity); stillPrivate(recordPath(stateDir, value.operationId), existing.record.identity);
|
|
109
|
+
if (directory(stateDir) !== pin) refuse(); return;
|
|
110
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
111
|
+
fresh(current, value); if (directory(stateDir) !== pin) refuse(); stillPrivate(keyPath(stateDir), key.identity);
|
|
112
|
+
publishNew(stateDir, recordPath(stateDir, value.operationId), encodeRecord(key.bytes, { binding: value, retired: false }));
|
|
113
|
+
fresh(current, value); if (directory(stateDir) !== pin) refuse();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** The host supplies expected registry/package binding and a fresh live-RPC or conservative offline policy check. */
|
|
117
|
+
export function authorizeConductorRecovery(stateDir: string, expected: ConductorRecoveryBinding, current: Current): VerifiedRecoveryAuthority {
|
|
118
|
+
return guarded(() => {
|
|
119
|
+
const value = binding(expected), pin = directory(stateDir); if (value.stateDir !== stateDir) refuse();
|
|
120
|
+
const proof = checkRecord(stateDir, value); if (proof.value.retired) refuse(); fresh(current, value);
|
|
121
|
+
stillPrivate(keyPath(stateDir), proof.key.identity); stillPrivate(recordPath(stateDir, value.operationId), proof.record.identity); if (directory(stateDir) !== pin) refuse();
|
|
122
|
+
const authority = Object.freeze({ binding: value });
|
|
123
|
+
grants.set(authority, { stateDir, directory: pin, key: proof.key.identity, record: proof.record.identity, current });
|
|
124
|
+
return authority;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/** Recheck immediately before every host effect, and after any awaited work. */
|
|
128
|
+
export function assertConductorRecovery(authority: VerifiedRecoveryAuthority): void {
|
|
129
|
+
return guarded(() => {
|
|
130
|
+
const grant = grants.get(authority); if (!grant) refuse();
|
|
131
|
+
if (directory(grant.stateDir) !== grant.directory) refuse();
|
|
132
|
+
const proof = checkRecord(grant.stateDir, authority.binding);
|
|
133
|
+
if (proof.value.retired || proof.key.identity !== grant.key || proof.record.identity !== grant.record) refuse();
|
|
134
|
+
fresh(grant.current, authority.binding);
|
|
135
|
+
stillPrivate(keyPath(grant.stateDir), grant.key); stillPrivate(recordPath(grant.stateDir, authority.binding.operationId), grant.record); if (directory(grant.stateDir) !== grant.directory) refuse();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Root calls only at an authorized terminal phase. A sealed retirement never reactivates through issuance. */
|
|
139
|
+
export function retireConductorRecovery(authority: VerifiedRecoveryAuthority): void {
|
|
140
|
+
return guarded(() => {
|
|
141
|
+
assertConductorRecovery(authority);
|
|
142
|
+
const grant = grants.get(authority)!;
|
|
143
|
+
const key = readPrivate(keyPath(grant.stateDir), 32, 32), file = recordPath(grant.stateDir, authority.binding.operationId);
|
|
144
|
+
const temporary = join(grant.stateDir, `.conductor-recovery.${randomUUID()}.tmp`);
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(temporary, encodeRecord(key.bytes, { binding: authority.binding, retired: true }), { flag: "wx", mode: 0o600 });
|
|
147
|
+
assertConductorRecovery(authority); renameSync(temporary, file);
|
|
148
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
149
|
+
grants.delete(authority);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/** Fresh, read-only privacy and policy proof for a host-sealed conductor recovery.
|
|
2
|
+
* This does not reconstruct the daemon's memory-only private-turn flag: the live
|
|
3
|
+
* admission path must also refuse contextPii before minting a recovery authority.
|
|
4
|
+
*/
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
8
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
9
|
+
import { types } from "node:util";
|
|
10
|
+
import { definitionOf } from "../adapters/registry.ts";
|
|
11
|
+
import type { HubConfig } from "./daemon.ts";
|
|
12
|
+
import type { ConfigSourceTexts } from "./config-reload.ts";
|
|
13
|
+
import { conductorPeer } from "./conductor.ts";
|
|
14
|
+
import { DEFAULT_ROLES } from "./hub-tools.ts";
|
|
15
|
+
import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
|
|
16
|
+
import { readInstanceConfig, type InstanceConfigLayer } from "./instance-config.ts";
|
|
17
|
+
import { effectiveInstanceRoles } from "./instances.ts";
|
|
18
|
+
import { realPath } from "./project.ts";
|
|
19
|
+
import { RecoveryAuthorityError } from "./recovery-authority.ts";
|
|
20
|
+
import { peerRosterConfig, rosterAllows } from "./roster.ts";
|
|
21
|
+
import { parseOverlay, parseRouting, type Routing } from "./routing.ts";
|
|
22
|
+
|
|
23
|
+
export interface RecoveryPolicyProject { root: string; id: string; stateDir: string }
|
|
24
|
+
export interface RecoveryPolicyCapture { policyDigest: string; boardDigest: string }
|
|
25
|
+
/** Host-captured inputs, shared with loadConfig before any module evaluation. */
|
|
26
|
+
export interface RecoveryPolicyInputs { layers: InstanceConfigLayer[]; routing: Routing; texts?: ConfigSourceTexts; routingFallback?: string }
|
|
27
|
+
const MAX_POLICY_BYTES = 1024 * 1024;
|
|
28
|
+
const MAX_ACTOR_TASKS = 4096;
|
|
29
|
+
const MAX_SIGNALS_BYTES = 65536;
|
|
30
|
+
function refuse(): never { throw new RecoveryAuthorityError(); }
|
|
31
|
+
const hash = (text: string): string => createHash("sha256").update(text).digest("hex");
|
|
32
|
+
const stamp = (s: NonNullable<ReturnType<typeof lstatSync>>): string => JSON.stringify([s.dev, s.ino, s.size, s.mode, s.nlink, s.mtimeMs, s.ctimeMs]);
|
|
33
|
+
|
|
34
|
+
/** Only bounded, regular, stable files; an absent optional policy is different from an unreadable one. */
|
|
35
|
+
function boundedText(file: string, optional = false): string | undefined {
|
|
36
|
+
let fd: number;
|
|
37
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
38
|
+
catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
|
|
39
|
+
try {
|
|
40
|
+
const before = fstatSync(fd);
|
|
41
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > MAX_POLICY_BYTES || realPath(file) !== file) refuse();
|
|
42
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
43
|
+
let length = 0;
|
|
44
|
+
while (length < bytes.length) { const n = readSync(fd, bytes, length, bytes.length - length, null); if (!n) break; length += n; }
|
|
45
|
+
if (length !== before.size || stamp(before) !== stamp(fstatSync(fd)) || stamp(before) !== stamp(lstatSync(file))) refuse();
|
|
46
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
|
|
47
|
+
} finally { closeSync(fd); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Derive compatibility from the verified target bytes, never the caller's mutable feature claim. */
|
|
51
|
+
export function readRecoveryTargetFeatures(targetRoot: string): readonly string[] {
|
|
52
|
+
try {
|
|
53
|
+
const source = boundedText(join(targetRoot, "src/hub/control-client.ts"))!;
|
|
54
|
+
// ponytail: one literal array, at most 128 fixed labels. A computed export needs a versioned target manifest.
|
|
55
|
+
const declarations = [...source.matchAll(/export const DAEMON_FEATURES\s*=\s*(\[[^\]]*\])(?:\s+as const)?\s*;/g)];
|
|
56
|
+
if (declarations.length !== 1) refuse();
|
|
57
|
+
const values: unknown = JSON.parse(declarations[0]![1]!);
|
|
58
|
+
if (!Array.isArray(values) || !values.length || values.length > 128 || values.some(value => typeof value !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(value))
|
|
59
|
+
|| new Set(values).size !== values.length || !values.includes("conductor_recovery")) refuse();
|
|
60
|
+
return Object.freeze([...values]);
|
|
61
|
+
} catch { return refuse(); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable semantic encoding; never evaluate config accessors or serialize runtime objects. */
|
|
65
|
+
function canonical(value: unknown, depth = 0): string {
|
|
66
|
+
if (depth > 32) refuse();
|
|
67
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
|
|
68
|
+
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
69
|
+
if (types.isProxy(value)) refuse();
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
if (Object.getPrototypeOf(value) !== Array.prototype || value.length > MAX_POLICY_BYTES) refuse();
|
|
72
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
73
|
+
if (Reflect.ownKeys(value).length !== value.length + 1 || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
for (let index = 0; index < value.length; index++) {
|
|
76
|
+
const field = fields[String(index)]; if (!field) refuse();
|
|
77
|
+
parts.push(canonical(field.value, depth + 1));
|
|
78
|
+
}
|
|
79
|
+
return `[${parts.join(",")}]`;
|
|
80
|
+
}
|
|
81
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) refuse();
|
|
82
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
83
|
+
if (Reflect.ownKeys(value).some(key => typeof key !== "string") || Object.values(fields).some(field => !Object.hasOwn(field, "value"))) refuse();
|
|
84
|
+
return `{${Object.keys(fields).sort().filter(key => fields[key]!.value !== undefined).map(key => `${JSON.stringify(key)}:${canonical(fields[key]!.value, depth + 1)}`).join(",")}}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function freshRoles(layers: readonly InstanceConfigLayer[], scope: ReturnType<typeof readInstanceConfig>): Record<string, string[]> {
|
|
88
|
+
const roles = structuredClone(DEFAULT_ROLES);
|
|
89
|
+
for (const { raw } of layers) if (raw.roles !== undefined) {
|
|
90
|
+
conductorPeer(raw.roles);
|
|
91
|
+
for (const [id, list] of Object.entries(raw.roles as Record<string, string[]>)) {
|
|
92
|
+
const definition = definitionOf(id), ceiling = definition?.routing?.allowedRoles;
|
|
93
|
+
roles[id] = (ceiling ? list.filter(role => ceiling.includes(role)) : list).filter(role => role !== "planner" || !definition || definition.projection?.plannerEligible === true);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const configured = Object.fromEntries(scope.catalog.definitions.filter(d => !definitionOf(d.kind)).map(d => [d.kind, [...d.roles]]));
|
|
97
|
+
const extras = Object.fromEntries(Object.keys(scope.instances ?? {}).map(id => [id, effectiveInstanceRoles(id, scope.instances, roles, scope.catalog)]));
|
|
98
|
+
return { ...configured, ...roles, ...extras };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publicActorBoard(stateDir: string, peer: string, routing: Routing): string {
|
|
102
|
+
const file = join(stateDir, "hub.db"), before = lstatSync(file);
|
|
103
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || realPath(file) !== file) refuse();
|
|
104
|
+
const db = new Database(file, { readonly: true });
|
|
105
|
+
db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
|
|
106
|
+
try {
|
|
107
|
+
db.run("BEGIN");
|
|
108
|
+
// Metadata only. In particular no unrelated task titles, bodies, history or refs are read.
|
|
109
|
+
const rows = db.query(`SELECT id, state, owner, reviewer,
|
|
110
|
+
CASE WHEN typeof(signals) = 'text' AND length(CAST(signals AS BLOB)) <= ${MAX_SIGNALS_BYTES} THEN signals ELSE NULL END AS signals
|
|
111
|
+
FROM tasks WHERE (owner = ? OR reviewer = ?) AND (state NOT IN ('approved', 'cancelled') OR state IS NULL)
|
|
112
|
+
ORDER BY id LIMIT ${MAX_ACTOR_TASKS + 1}`).all(peer, peer) as { id: number; state: string; owner: string | null; reviewer: string | null; signals: string | null }[];
|
|
113
|
+
if (rows.length > MAX_ACTOR_TASKS) refuse();
|
|
114
|
+
let total = 0;
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (!Number.isSafeInteger(row.id) || row.id <= 0 || !["proposed", "in_progress", "in_review", "changes_requested"].includes(row.state)
|
|
117
|
+
|| row.signals === null || (total += Buffer.byteLength(row.signals)) > MAX_POLICY_BYTES) refuse();
|
|
118
|
+
const signals: unknown = JSON.parse(row.signals);
|
|
119
|
+
if (!Array.isArray(signals) || signals.length > 1024 || signals.some(signal => typeof signal !== "string" || signal.length > 1024)) refuse();
|
|
120
|
+
if (signals.includes("pii_path") || signals.includes("pii") && routing.constraints.pii === "local_only") refuse();
|
|
121
|
+
}
|
|
122
|
+
db.run("COMMIT");
|
|
123
|
+
const after = lstatSync(file);
|
|
124
|
+
if (after.dev !== before.dev || after.ino !== before.ino || realPath(file) !== file) refuse();
|
|
125
|
+
// Unrelated public board activity must not revoke a public conductor's offline continuity.
|
|
126
|
+
return hash(canonical({ schema: 1, peer, private: false }));
|
|
127
|
+
} finally { db.close(); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A protected pending edit is not authorization to apply it through a restart. */
|
|
131
|
+
export function assertRecoveryConfiguration(accepted: HubConfig, candidate: HubConfig): void {
|
|
132
|
+
try {
|
|
133
|
+
const previous = canonical(accepted), next = canonical(candidate);
|
|
134
|
+
if (Buffer.byteLength(previous) > 8 * MAX_POLICY_BYTES || Buffer.byteLength(next) > 8 * MAX_POLICY_BYTES || previous !== next) refuse();
|
|
135
|
+
} catch { return refuse(); }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function assertRecoveryRouting(accepted: Routing, candidate: Routing): void {
|
|
139
|
+
try { if (canonical(accepted) !== canonical(candidate)) refuse(); }
|
|
140
|
+
catch { refuse(); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Unknown future keys are still protected inputs; an older source may not silently authorize a pending edit. */
|
|
144
|
+
export function assertRecoverySourceInputs(accepted: readonly { name: string; raw: Record<string, unknown> }[], candidate: RecoveryPolicyInputs): void {
|
|
145
|
+
try {
|
|
146
|
+
const actual: { name: string; raw: Record<string, unknown> }[] = [...candidate.layers];
|
|
147
|
+
for (const name of ["routing.toml", "routing.local.toml"] as const) {
|
|
148
|
+
const text = candidate.texts?.[name];
|
|
149
|
+
if (text != null) {
|
|
150
|
+
const raw = Bun.TOML.parse(text);
|
|
151
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
|
|
152
|
+
actual.push({ name, raw: raw as Record<string, unknown> });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const projection = (rows: readonly { name: string; raw: Record<string, unknown> }[]) => Object.fromEntries(rows.filter(row => Object.keys(row.raw).length > 0).map(row => [row.name, row.raw]));
|
|
156
|
+
if (canonical(projection(accepted)) !== canonical(projection(actual))) refuse();
|
|
157
|
+
} catch { refuse(); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One bounded source capture feeds both loadConfig and the recovery proof. No module is evaluated. */
|
|
161
|
+
export function recoveryRoutingFallback(): string {
|
|
162
|
+
try { return boundedText(resolve(import.meta.dir, "..", "..", "templates", "routing.toml"))!; } catch { return refuse(); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function readRecoveryPolicyInputs(root: string, fallback?: string): RecoveryPolicyInputs {
|
|
166
|
+
try {
|
|
167
|
+
if (!isAbsolute(root) || realPath(root) !== root) refuse();
|
|
168
|
+
const layers: InstanceConfigLayer[] = [];
|
|
169
|
+
const texts: ConfigSourceTexts = { "config.json": null, "config.local.json": null, "routing.toml": null, "routing.local.toml": null };
|
|
170
|
+
for (const name of ["config.json", "config.local.json"] as const) {
|
|
171
|
+
const text = boundedText(join(root, ".agenthub", name), true);
|
|
172
|
+
texts[name] = text ?? null;
|
|
173
|
+
if (text === undefined) continue;
|
|
174
|
+
const raw: unknown = JSON.parse(text);
|
|
175
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) refuse();
|
|
176
|
+
layers.push({ name, raw: raw as Record<string, unknown> });
|
|
177
|
+
}
|
|
178
|
+
const ownMain = boundedText(join(root, ".agenthub", "routing.toml"), true);
|
|
179
|
+
const main = ownMain ?? fallback ?? recoveryRoutingFallback();
|
|
180
|
+
if (Buffer.byteLength(main) > MAX_POLICY_BYTES) refuse();
|
|
181
|
+
const overlay = boundedText(join(root, ".agenthub", "routing.local.toml"), true);
|
|
182
|
+
texts["routing.toml"] = ownMain ?? null; texts["routing.local.toml"] = overlay ?? null;
|
|
183
|
+
return { layers, routing: parseRouting(main, overlay === undefined ? undefined : parseOverlay(overlay)), texts, ...(ownMain === undefined ? { routingFallback: main } : {}) };
|
|
184
|
+
} catch { return refuse(); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** currentConfig must be host-loaded from these exact inputs and compared with the running accepted config.
|
|
188
|
+
* Supplied inputs are never reread from raw config/routing files. Board privacy is always fresh.
|
|
189
|
+
*/
|
|
190
|
+
export function captureRecoveryPolicy(project: RecoveryPolicyProject, peer: string, currentConfig: HubConfig, inputs?: RecoveryPolicyInputs): RecoveryPolicyCapture {
|
|
191
|
+
try {
|
|
192
|
+
if (!project.id || !isAbsolute(project.root) || !isAbsolute(project.stateDir) || realPath(project.root) !== project.root || realPath(project.stateDir) !== project.stateDir) refuse();
|
|
193
|
+
const snapshot = canonical(inputs ?? readRecoveryPolicyInputs(project.root));
|
|
194
|
+
if (Buffer.byteLength(snapshot) > 8 * MAX_POLICY_BYTES) refuse();
|
|
195
|
+
// Copy host own-data fields once, so proof evaluation cannot run callbacks or observe mutation mid-capture.
|
|
196
|
+
const captured = JSON.parse(snapshot) as RecoveryPolicyInputs;
|
|
197
|
+
const { layers, routing } = captured;
|
|
198
|
+
if (!Array.isArray(layers) || layers.length > 2 || new Set(layers.map(layer => layer.name)).size !== layers.length
|
|
199
|
+
|| layers.some(layer => !["config.json", "config.local.json"].includes(layer.name) || !layer.raw || typeof layer.raw !== "object" || Array.isArray(layer.raw))) refuse();
|
|
200
|
+
// Explicit empty runtime definitions: never import a module or trust a retained display catalog.
|
|
201
|
+
const scope = readInstanceConfig(project.root, layers, []);
|
|
202
|
+
if (scope.adapter_modules?.length || scope.declaredExternalInstances?.length || currentConfig.adapter_modules?.length) refuse();
|
|
203
|
+
const roles = freshRoles(layers, scope);
|
|
204
|
+
if (conductorPeer(roles) !== peer || conductorPeer(currentConfig.roles) !== peer || canonical(roles) !== canonical(currentConfig.roles)
|
|
205
|
+
|| canonical(scope.instances ?? {}) !== canonical(currentConfig.instances ?? {}) || scope.instances?.[peer]?.readOnly === true
|
|
206
|
+
|| !scope.catalog.definitionOf(peer, scope.instances)) refuse();
|
|
207
|
+
// roster is merged key by key by loadConfig; independently refresh the exact fields that admit the actor.
|
|
208
|
+
const rawRoster = Object.assign({}, ...layers.map(layer => layer.raw.roster ?? {}));
|
|
209
|
+
const roster = peerRosterConfig(rawRoster, scope.instances, scope.catalog);
|
|
210
|
+
if (canonical(roster) !== canonical(currentConfig.roster) || !rosterAllows(roster, peer, currentConfig.pi.enabled, scope.instances, scope.catalog)) refuse();
|
|
211
|
+
// ponytail: at most 2 MiB config, 2 MiB routing and 4096 actor-bound open rows (1 MiB signals).
|
|
212
|
+
// A larger board fails closed; the upgrade path is an indexed durable per-actor privacy generation.
|
|
213
|
+
// Seal version-neutral inputs and the fresh actor decision. Defaults and registry projections may evolve in the verified target.
|
|
214
|
+
const policy = canonical({ schema: 1, project: { root: project.root, id: project.id, stateDir: project.stateDir }, peer,
|
|
215
|
+
actor: { conductor: true, rostered: true, writable: true }, sources: layers, texts: captured.texts ?? null,
|
|
216
|
+
routingFallback: captured.routingFallback ?? null });
|
|
217
|
+
if (Buffer.byteLength(policy) > 8 * MAX_POLICY_BYTES) refuse();
|
|
218
|
+
return { policyDigest: hash(policy), boardDigest: publicActorBoard(project.stateDir, peer, routing) };
|
|
219
|
+
} catch { return refuse(); }
|
|
220
|
+
}
|
package/src/hub/research.ts
CHANGED
|
@@ -58,8 +58,8 @@ export interface TaskRecord {
|
|
|
58
58
|
/** #258 AC11: task briefs handed over for it, and how many of them carried a hub-memory fail note; absent without briefs. */
|
|
59
59
|
briefs?: { delivered: number; withFail: number };
|
|
60
60
|
}
|
|
61
|
-
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. */
|
|
62
|
-
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string }
|
|
61
|
+
/** A person's later verdict on one task record: `createdAt` binds it to that task, not a later one with the same id. `by`: the agent that labelled its own task (#933); absent for the person. */
|
|
62
|
+
export interface LabelRecord { schema: typeof RESEARCH_SCHEMA; kind: "label"; project: string; task: number; createdAt: string; label: Label; at: string; by?: string }
|
|
63
63
|
export type ResearchRecord = TaskRecord | LabelRecord;
|
|
64
64
|
|
|
65
65
|
/** The store name for a project: a hash of its id, never its path. */
|
|
@@ -293,15 +293,26 @@ export function formatResearch(r: ResearchReport): string[] {
|
|
|
293
293
|
return [head, row("all approved tasks", r.overall), ...section("by class", r.byClass), ...section("by owner", r.byOwner), ...section("by model", r.byModel), ...section("by project", r.byProject), ...(Object.keys(r.byMemoryBrief).some((k) => k !== "no brief recorded") ? section("by memory brief", r.byMemoryBrief) : [])];
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
/**
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
/**
|
|
297
|
+
* The label each record counts with: the person's latest label, whatever came after it; an agent's (#933, `by` set) only when the person
|
|
298
|
+
* never labelled that record, the latest of them then. An agent's verdict on its own task never replaces the person's.
|
|
299
|
+
*/
|
|
300
|
+
function effectiveLabels(records: readonly ResearchRecord[]): Map<string, LabelRecord> {
|
|
301
|
+
const labels = new Map<string, LabelRecord>();
|
|
299
302
|
for (const r of records) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
+
if (r.kind !== "label") continue;
|
|
304
|
+
const key = recordKey(r), held = labels.get(key);
|
|
305
|
+
if (r.by === undefined || held === undefined || held.by !== undefined) labels.set(key, r);
|
|
303
306
|
}
|
|
304
|
-
return
|
|
307
|
+
return labels;
|
|
308
|
+
}
|
|
309
|
+
const labelValues = (labels: Map<string, LabelRecord>): Map<string, Label> => new Map([...labels].map(([key, r]) => [key, r.label]));
|
|
310
|
+
|
|
311
|
+
/** Each approved task once, its last record, and every task's effective label: what both reports count. */
|
|
312
|
+
function latestRecords(records: ResearchRecord[], since: number): { all: TaskRecord[]; labels: Map<string, Label> } {
|
|
313
|
+
const latest = new Map<string, TaskRecord>();
|
|
314
|
+
for (const r of records) if (r.kind === "task" && Date.parse(r.approvedAt) >= since) latest.set(recordKey(r), r);
|
|
315
|
+
return { all: [...latest.values()], labels: labelValues(effectiveLabels(records)) };
|
|
305
316
|
}
|
|
306
317
|
|
|
307
318
|
/** The peers that worked on a task: every peer with an attributed event, else (older records) owners, reviewer and token peers. */
|
|
@@ -364,15 +375,14 @@ export function formatAddons(rows: AddonComparison[]): string[] {
|
|
|
364
375
|
|
|
365
376
|
export const CSV_COLUMNS = ["project", "task", "class", "pii", "outcome", "createdAt", "startedAt", "approvedAt", "wallMs", "activeMs", "owners",
|
|
366
377
|
"reviewer", "reviewRounds", "changesRequested", "checkPassed", "checkFailed", "dones", "reassignments", "firstPass", "stuck", "overlaps", "conflicts",
|
|
367
|
-
"testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "writerVersion", "writerSource"] as const;
|
|
368
|
-
/** Task records as CSV with a header row, one row per record, each with
|
|
378
|
+
"testsPass", "testsFail", "tokens", "turns", "filesChanged", "models", "label", "labelBy", "writerVersion", "writerSource"] as const;
|
|
379
|
+
/** Task records as CSV with a header row, one row per record, each with its effective label and who gave it (empty: the person); JSONL has every field. */
|
|
369
380
|
export function toCsv(records: ResearchRecord[]): string {
|
|
370
|
-
const labels =
|
|
371
|
-
for (const r of records) if (r.kind === "label") labels.set(recordKey(r), r.label);
|
|
381
|
+
const labels = effectiveLabels(records);
|
|
372
382
|
const cell = (v: unknown) => { const s = v === null || v === undefined ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; };
|
|
373
383
|
const rows = records.filter((r): r is TaskRecord => r.kind === "task").map((r) => [r.project, r.task, r.class, r.pii, r.outcome, r.createdAt, r.startedAt,
|
|
374
384
|
r.approvedAt, r.wallMs, r.activeMs, r.owners.join(" "), r.reviewer, r.reviewRounds, r.changesRequested, r.checkPassed, r.checkFailed, r.dones,
|
|
375
385
|
r.reassignments, r.firstPass, r.stuck, r.overlaps, r.conflicts, r.tests.pass, r.tests.fail, r.tokens.total, r.turns, r.filesChanged, r.models.join(" "),
|
|
376
|
-
labels.get(recordKey(r)) ?? "", r.writer.version, r.writer.source].map(cell).join(","));
|
|
386
|
+
labels.get(recordKey(r))?.label ?? "", labels.get(recordKey(r))?.by ?? "", r.writer.version, r.writer.source].map(cell).join(","));
|
|
377
387
|
return [CSV_COLUMNS.join(","), ...rows].join("\n") + "\n";
|
|
378
388
|
}
|
package/src/hub/restart.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { MAX_HOP } from "./envelope.ts";
|
|
|
7
7
|
import { validateJournalSnapshot } from "./delivery-journal.ts";
|
|
8
8
|
import { atomicPrivateJSON } from "./recovery-store.ts";
|
|
9
9
|
import { RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
10
|
+
import { validExternalInstanceBinding, type ExternalInstanceBinding } from "./external-instance.ts";
|
|
10
11
|
|
|
11
12
|
export const RESTART_SCHEMA_VERSION = 1;
|
|
12
13
|
|
|
@@ -24,6 +25,8 @@ export interface RestartPeerSnapshot {
|
|
|
24
25
|
* cannot be resumed and a fresh session is accepted instead. Absent in snapshots written
|
|
25
26
|
* before this flag; those are re-derived from disk (#64). */
|
|
26
27
|
sessionPersisted?: boolean;
|
|
28
|
+
/** Retained stable identity for an explicitly stopped module instance; never authorizes automatic resume. */
|
|
29
|
+
externalInstance?: ExternalInstanceBinding;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export interface RestartSnapshot {
|
|
@@ -41,6 +44,8 @@ export interface RestartSnapshot {
|
|
|
41
44
|
manualPaused: PeerId[];
|
|
42
45
|
boardDigest: string;
|
|
43
46
|
budgetDigest: string;
|
|
47
|
+
/** Whole captured binding set; portable identities are covered alongside queue/board integrity. */
|
|
48
|
+
externalBindingsDigest?: string;
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -87,7 +92,9 @@ export function readRestartSnapshot(stateDir: string, expected: RestartReadExpec
|
|
|
87
92
|
}, occupied);
|
|
88
93
|
} catch (error) { if (error instanceof RecoveryDeferredHubError) throw error; return undefined; }
|
|
89
94
|
if (!Array.isArray(s.manualPaused) || !s.manualPaused.every((id) => typeof id === "string") || !Array.isArray(s.peers) || !s.peers.every((peer) => peer && typeof peer === "object" && typeof peer.id === "string" && ["idle", "busy", "paused", "offline"].includes(peer.state as string) && Array.isArray(peer.queueIds) && peer.queueIds.every((id) => typeof id === "string"))) return undefined;
|
|
95
|
+
if (s.peers.some(peer => Object.hasOwn(peer, "externalInstance") && (peer.state !== "offline" || !validExternalInstanceBinding(peer.externalInstance, expected.projectRoot, peer.id)))) return undefined;
|
|
90
96
|
if (s.integrity !== undefined && (!s.integrity || typeof s.integrity !== "object" || typeof s.integrity.boardDigest !== "string" || typeof s.integrity.budgetDigest !== "string" || !Array.isArray(s.integrity.manualPaused) || !s.integrity.manualPaused.every((id) => typeof id === "string") || !s.integrity.queues || typeof s.integrity.queues !== "object" || !Object.values(s.integrity.queues).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")))) return undefined;
|
|
97
|
+
if (s.integrity && Object.hasOwn(s.integrity, "externalBindingsDigest") && (typeof s.integrity.externalBindingsDigest !== "string" || !/^[a-f0-9]{64}$/.test(s.integrity.externalBindingsDigest))) return undefined;
|
|
91
98
|
if (bus.journal !== undefined) { try { validateJournalSnapshot(bus.journal); } catch { return undefined; } }
|
|
92
99
|
return s as RestartSnapshot;
|
|
93
100
|
}
|