@felixdotgo/aidlc-kit 0.0.1 → 0.1.1
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/DEVELOPMENT.md +1 -1
- package/README.md +20 -9
- package/dist/assets/.agents/aidlc/conventions.md +8 -8
- package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
- package/dist/assets/.agents/aidlc/phase-build.md +7 -7
- package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
- package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
- package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +1 -1
- package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
- package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +13 -1
- package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +220 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +320 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +284 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +162 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +260 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +66 -0
- package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +41 -6
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
- package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
- package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +311 -0
- package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
- package/dist/assets/.agents/aidlc/scripts/state.mjs +71 -11
- package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
- package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
- package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
- package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
- package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
- package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
- package/dist/services/aidlc-coordination/compose.yaml +23 -2
- package/dist/services/aidlc-coordination/package-lock.json +1 -0
- package/dist/services/aidlc-coordination/package.json +2 -1
- package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
- package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
- package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
- package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
- package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +41 -6
- package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
- package/dist/services/aidlc-coordination/src/server.mjs +53 -9
- package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
- package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
- package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
- package/dist/src/adapters.js +32 -5
- package/dist/src/cli.js +146 -70
- package/dist/src/context.js +2 -0
- package/dist/src/gate.d.ts +1 -0
- package/dist/src/gate.js +51 -38
- package/dist/src/installer.d.ts +10 -1
- package/dist/src/installer.js +157 -37
- package/dist/src/managed-content.js +1 -1
- package/dist/src/model.d.ts +16 -1
- package/dist/src/model.js +1 -1
- package/dist/src/profiles.d.ts +9 -1
- package/dist/src/profiles.js +37 -3
- package/dist/src/project-migration.d.ts +1 -0
- package/dist/src/project-migration.js +7 -0
- package/dist/src/state/archive.d.ts +17 -0
- package/dist/src/state/archive.js +60 -0
- package/dist/src/state/items.d.ts +25 -0
- package/dist/src/state/items.js +146 -0
- package/dist/src/state/lessons.d.ts +7 -0
- package/dist/src/state/lessons.js +67 -0
- package/dist/src/state/lifecycle.d.ts +5 -0
- package/dist/src/state/lifecycle.js +78 -0
- package/dist/src/state/lock.d.ts +7 -0
- package/dist/src/state/lock.js +124 -0
- package/dist/src/state/memory.d.ts +8 -0
- package/dist/src/state/memory.js +88 -0
- package/dist/src/state/render.d.ts +26 -0
- package/dist/src/state/render.js +29 -0
- package/dist/src/state/shared.d.ts +29 -0
- package/dist/src/state/shared.js +71 -0
- package/dist/src/state/store.d.ts +4 -0
- package/dist/src/state/store.js +81 -0
- package/dist/src/state/transitions.d.ts +21 -0
- package/dist/src/state/transitions.js +93 -0
- package/dist/src/state/validate.d.ts +4 -0
- package/dist/src/state/validate.js +100 -0
- package/dist/src/state.d.ts +15 -110
- package/dist/src/state.js +15 -764
- package/dist/src/tracker.d.ts +7 -0
- package/dist/src/tracker.js +3 -0
- package/dist/src/upgrade-board.d.ts +5 -0
- package/dist/src/upgrade-board.js +89 -0
- package/dist/src/upgrade.d.ts +5 -6
- package/dist/src/upgrade.js +211 -193
- package/dist/src/workflow.d.ts +1 -0
- package/dist/src/workflow.js +3 -2
- package/docs/README.md +4 -4
- package/docs/architecture.md +10 -2
- package/docs/command-reference.md +72 -11
- package/docs/configuration.md +26 -4
- package/docs/coordination-service.md +59 -3
- package/docs/development.md +16 -2
- package/docs/operating-workflow.md +53 -14
- package/docs/testing-and-release.md +53 -10
- package/package.json +3 -3
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { assertLoopPath } from "./loop-lease.mjs";
|
|
5
|
+
import { validateRoot } from "./loop-runtime.mjs";
|
|
6
|
+
import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
|
|
7
|
+
import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
|
|
8
|
+
|
|
9
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
10
|
+
const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
|
|
11
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };
|
|
12
|
+
const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13
|
+
|
|
14
|
+
function readLease(path, taskId) {
|
|
15
|
+
try {
|
|
16
|
+
const lease = JSON.parse(readFileSync(path, "utf8"));
|
|
17
|
+
if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
|
|
18
|
+
return lease;
|
|
19
|
+
} catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeExclusive(path, value) {
|
|
23
|
+
const fd = openSync(path, "wx", 0o600);
|
|
24
|
+
try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Local durable checkpoint + separate PID-fenced supervisor lock.
|
|
28
|
+
* The lock uses a distinct task key from the runner's lease so humans can approve.
|
|
29
|
+
* This adapter intentionally does not implement runTurn or perform approvals.
|
|
30
|
+
*/
|
|
31
|
+
export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
|
|
32
|
+
const root = validateRoot(rootInput);
|
|
33
|
+
const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
|
|
34
|
+
const leasePath = assertLoopPath(root, taskId, ".supervisor.lease");
|
|
35
|
+
const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
|
|
36
|
+
let token;
|
|
37
|
+
const withGuard = (action) => {
|
|
38
|
+
assertLoopPath(root, taskId, ".supervisor.guard");
|
|
39
|
+
mkdirSync(dirname(guardPath), { recursive: true });
|
|
40
|
+
const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
|
|
41
|
+
for (;;) {
|
|
42
|
+
try { writeExclusive(guardPath, guard); break; }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error?.code !== "EEXIST") throw error;
|
|
45
|
+
const existing = readLease(guardPath, taskId);
|
|
46
|
+
if (!existing) continue;
|
|
47
|
+
if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
|
|
48
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
|
|
49
|
+
wait(10);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
|
|
53
|
+
};
|
|
54
|
+
const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
|
|
55
|
+
const assertOwned = async () => {
|
|
56
|
+
assertLoopPath(root, taskId, ".supervisor.lease");
|
|
57
|
+
const current = readLease(leasePath, taskId);
|
|
58
|
+
if (!token || !current || current.token !== token || current.pid !== process.pid) throw new Error("local supervisor ownership lost");
|
|
59
|
+
};
|
|
60
|
+
const readCheckpoint = () => {
|
|
61
|
+
assertLoopPath(root, taskId, ".supervisor.json");
|
|
62
|
+
try {
|
|
63
|
+
const value = JSON.parse(readFileSync(checkpointPath, "utf8"));
|
|
64
|
+
if (!value || value.version !== 1 || value.taskId !== taskId || value.authority !== "local") throw new Error("wrong task or authority");
|
|
65
|
+
return value;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ENOENT") return null;
|
|
68
|
+
throw new Error(`Corrupt supervisor checkpoint for ${taskId}: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const syncDir = () => { const dir = openSync(dirname(checkpointPath), "r"); try { fsyncSync(dir); } finally { closeSync(dir); } };
|
|
72
|
+
return {
|
|
73
|
+
async acquireSupervisor(input) {
|
|
74
|
+
checkScope(input);
|
|
75
|
+
if (token) throw new Error("local supervisor adapter is already acquired");
|
|
76
|
+
if (input.signal?.aborted) throw new Error("supervisor already aborted");
|
|
77
|
+
const newToken = randomUUID();
|
|
78
|
+
withGuard(() => {
|
|
79
|
+
assertLoopPath(root, taskId, ".supervisor.lease");
|
|
80
|
+
const existing = readLease(leasePath, taskId);
|
|
81
|
+
if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
|
|
82
|
+
if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
|
|
83
|
+
writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
|
|
84
|
+
});
|
|
85
|
+
token = newToken;
|
|
86
|
+
return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
|
|
87
|
+
},
|
|
88
|
+
async readTask(input) {
|
|
89
|
+
checkScope({ ...input, authority: "local" });
|
|
90
|
+
const release = acquireStateLock(root);
|
|
91
|
+
try {
|
|
92
|
+
if (token) await assertOwned();
|
|
93
|
+
const task = loadTask(root, taskId);
|
|
94
|
+
if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
|
|
95
|
+
if (token) {
|
|
96
|
+
await recoverLeadArtifact({ root, taskId, task, assertOwned });
|
|
97
|
+
await assertOwned();
|
|
98
|
+
} else {
|
|
99
|
+
const journal = join(root, ".agents", "data", "tasks", taskId, ".lead-artifact-journal.json");
|
|
100
|
+
try { lstatSync(journal); throw new Error("Pending lead artifact journal requires supervised recovery"); }
|
|
101
|
+
catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
102
|
+
}
|
|
103
|
+
return { task, nextAction: nextAction(task, root) };
|
|
104
|
+
} finally { release(); }
|
|
105
|
+
},
|
|
106
|
+
async loadCheckpoint(input) { checkScope(input); await assertOwned(); return readCheckpoint(); },
|
|
107
|
+
async saveCheckpoint(input) {
|
|
108
|
+
checkScope(input); await assertOwned();
|
|
109
|
+
if (!input.checkpoint || input.checkpoint.taskId !== taskId || input.checkpoint.authority !== "local") throw new Error("invalid local checkpoint");
|
|
110
|
+
if (!same(readCheckpoint(), input.previous ?? null)) throw new Error("supervisor checkpoint conflict");
|
|
111
|
+
mkdirSync(dirname(checkpointPath), { recursive: true });
|
|
112
|
+
const temporary = `${checkpointPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
113
|
+
let fd;
|
|
114
|
+
try {
|
|
115
|
+
fd = openSync(temporary, "wx", 0o600); writeFileSync(fd, `${JSON.stringify(input.checkpoint)}\n`); fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
116
|
+
await assertOwned();
|
|
117
|
+
if (!same(readCheckpoint(), input.previous ?? null)) throw new Error("supervisor checkpoint conflict");
|
|
118
|
+
renameSync(temporary, checkpointPath); syncDir();
|
|
119
|
+
} finally {
|
|
120
|
+
if (fd !== undefined) closeSync(fd);
|
|
121
|
+
if (existsSync(temporary)) unlinkSync(temporary);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async clearCheckpoint(input) {
|
|
125
|
+
checkScope(input); await assertOwned();
|
|
126
|
+
const current = readCheckpoint();
|
|
127
|
+
if (!same(current, input.checkpoint)) throw new Error("supervisor checkpoint conflict");
|
|
128
|
+
if (current) { unlinkSync(checkpointPath); syncDir(); }
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { requireLocalAuthority } from "./authority.mjs";
|
|
3
|
+
import { runAgentTurn, isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
4
|
+
import { readLoopRecord, runLoop, validateRoot } from "./loop-runtime.mjs";
|
|
5
|
+
import { runSupervisor } from "./loop-supervisor.mjs";
|
|
6
|
+
import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
|
|
7
|
+
|
|
8
|
+
const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bind the canonical local store, distinct supervisor lease and the existing
|
|
12
|
+
* leased runner. The caller owns process supervision and human gate approval.
|
|
13
|
+
* Abort stops polling or the next turn; the current runLoop call is bounded by
|
|
14
|
+
* its existing timeouts and is not cancelled by this wrapper.
|
|
15
|
+
*/
|
|
16
|
+
export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, onEvent = () => {}, runner = runLoop } = {}) {
|
|
17
|
+
requireLocalAuthority();
|
|
18
|
+
const root = validateRoot(rootInput);
|
|
19
|
+
if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
|
|
20
|
+
if (typeof driver !== "function" || typeof runner !== "function" || typeof onEvent !== "function") throw new Error("invalid local supervisor callbacks");
|
|
21
|
+
const adapter = createLocalSupervisorAdapter({ root, taskId });
|
|
22
|
+
const getRecord = () => readLoopRecord(root, taskId);
|
|
23
|
+
const execute = async (mode) => {
|
|
24
|
+
requireLocalAuthority();
|
|
25
|
+
const record = getRecord();
|
|
26
|
+
if (mode === "run" && record) throw new Error("Local loop record already exists; use resume");
|
|
27
|
+
if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
|
|
28
|
+
if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
|
|
29
|
+
let nextMode = mode;
|
|
30
|
+
return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, onEvent,
|
|
31
|
+
runTurn: async ({ precondition }) => {
|
|
32
|
+
if (signal?.aborted) return { status: "stopped", taskId };
|
|
33
|
+
requireLocalAuthority();
|
|
34
|
+
let expected = precondition;
|
|
35
|
+
if (!expected) {
|
|
36
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
37
|
+
if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
|
|
38
|
+
expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
|
|
39
|
+
}
|
|
40
|
+
const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent });
|
|
41
|
+
nextMode = "resume";
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
start: () => execute("run"),
|
|
48
|
+
resume: () => execute("resume"),
|
|
49
|
+
async status() {
|
|
50
|
+
requireLocalAuthority();
|
|
51
|
+
const snapshot = await adapter.readTask({ taskId });
|
|
52
|
+
return { taskId, authority: "local", record: getRecord(), nextAction: snapshot.nextAction };
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
const TARGET = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
|
|
5
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
7
|
+
const required = (value, name) => { if (typeof value !== "function") throw new Error(`${name} is required`); return value; };
|
|
8
|
+
const validId = (value, name) => { if (typeof value !== "string" || !ID.test(value)) throw new Error(`invalid ${name}`); };
|
|
9
|
+
|
|
10
|
+
function validateSnapshot(snapshot, taskId) {
|
|
11
|
+
if (!snapshot || snapshot.task?.id !== taskId || !Array.isArray(snapshot.task.evidence) || !snapshot.nextAction || !["run_phase", "await_user", "blocked", "terminal", "complete"].includes(snapshot.nextAction.classification)) throw new Error("invalid canonical task snapshot");
|
|
12
|
+
return snapshot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Checkpoints contain a digest, not the task evidence or user-provided approval text. */
|
|
16
|
+
export function makeGateCheckpoint({ taskId, authority, snapshot, result }) {
|
|
17
|
+
validId(taskId, "task ID");
|
|
18
|
+
if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
|
|
19
|
+
validateSnapshot(snapshot, taskId);
|
|
20
|
+
const gate = result?.gate ?? result?.nextAction?.gate;
|
|
21
|
+
if (!TARGET[gate] || snapshot.task.gate !== gate || snapshot.task.status !== "blocked_on_user" || snapshot.nextAction.classification !== "await_user" || (result.taskId && result.taskId !== taskId)) throw new Error("gate changed before supervisor checkpoint; inspect state manually");
|
|
22
|
+
if (authority === "fleet" && (!result.snapshotId || !Number.isSafeInteger(snapshot.revision))) throw new Error("fleet gate requires snapshot ID and revision");
|
|
23
|
+
return { version: 1, taskId, authority, gate, phase: snapshot.task.phase, taskHash: hash(snapshot.task), evidenceCount: snapshot.task.evidence.length, evidenceHash: hash(snapshot.task.evidence), ...(authority === "fleet" ? { snapshotId: result.snapshotId, revision: snapshot.revision } : {}) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Return wait/resume/blocked only from fresh canonical state, never grant approval. */
|
|
27
|
+
export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval }) {
|
|
28
|
+
if (!checkpoint || checkpoint.version !== 1 || !TARGET[checkpoint.gate] || !["local", "fleet"].includes(checkpoint.authority) || !Number.isSafeInteger(checkpoint.evidenceCount) || checkpoint.evidenceCount < 0 || typeof checkpoint.evidenceHash !== "string" || typeof checkpoint.taskHash !== "string") throw new Error("invalid gate checkpoint");
|
|
29
|
+
validId(checkpoint.taskId, "checkpoint task ID");
|
|
30
|
+
validateSnapshot(snapshot, checkpoint.taskId);
|
|
31
|
+
const { task, nextAction } = snapshot;
|
|
32
|
+
if (task.evidence.length < checkpoint.evidenceCount || hash(task.evidence.slice(0, checkpoint.evidenceCount)) !== checkpoint.evidenceHash) return { status: "blocked", reason: "gate evidence changed since checkpoint" };
|
|
33
|
+
if (task.status === "paused" || task.handoff || ["blocked", "terminal", "complete"].includes(nextAction.classification)) return { status: "blocked", reason: "task is paused, blocked, or terminal" };
|
|
34
|
+
if (task.phase === checkpoint.phase && task.gate === checkpoint.gate && task.status === "blocked_on_user" && nextAction.classification === "await_user") {
|
|
35
|
+
if (checkpoint.authority === "fleet" && hash(task) !== checkpoint.taskHash) return { status: "blocked", reason: "fleet review snapshot is stale" };
|
|
36
|
+
return { status: "wait" };
|
|
37
|
+
}
|
|
38
|
+
const appended = task.evidence.slice(checkpoint.evidenceCount);
|
|
39
|
+
const approvals = appended.filter((entry) => entry.kind === "approval" && entry.gate === checkpoint.gate && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim());
|
|
40
|
+
if (task.phase !== TARGET[checkpoint.gate] || task.status !== "active" || nextAction.classification !== "run_phase" || approvals.length !== 1) return { status: "blocked", reason: "gate changed without its matching approval" };
|
|
41
|
+
if (checkpoint.authority === "local" && verifyLocalApproval && !await verifyLocalApproval({ checkpoint, snapshot })) return { status: "blocked", reason: "local approval lacks the matching host receipt" };
|
|
42
|
+
if (checkpoint.authority === "fleet") {
|
|
43
|
+
if (!checkpoint.snapshotId || !Number.isSafeInteger(checkpoint.revision) || !Number.isSafeInteger(snapshot.revision) || snapshot.revision <= checkpoint.revision) return { status: "blocked", reason: "fleet gate revision is stale" };
|
|
44
|
+
if (!await required(verifyFleetApproval, "verifyFleetApproval")({ checkpoint, snapshot })) return { status: "blocked", reason: "fleet approval does not match review snapshot" };
|
|
45
|
+
}
|
|
46
|
+
return { status: "resume", precondition: { taskId: checkpoint.taskId, gate: checkpoint.gate, phase: task.phase, taskHash: hash(task), evidenceCount: task.evidence.length, evidenceHash: hash(task.evidence), ...(checkpoint.authority === "fleet" ? { revision: snapshot.revision, snapshotId: checkpoint.snapshotId } : {}) } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Supervises short runner invocations. runTurn MUST acquire its existing exclusive
|
|
51
|
+
* loop lease/lead epoch, reread canonical state, and enforce the supplied precondition
|
|
52
|
+
* before doing any work; if no precondition is supplied it MUST still verify
|
|
53
|
+
* run_phase under the lease. It MUST release the lease before returning a gate. Fleet
|
|
54
|
+
* runTurn additionally reconciles uncertain jobs and Git before dispatch. The
|
|
55
|
+
* checkpoint adapter MUST persist atomically, be scoped to task+authority, and be
|
|
56
|
+
* concurrency safe. acquireSupervisor MUST provide an independently fenced,
|
|
57
|
+
* renewable exclusive supervisor lease that does not block human gate approval.
|
|
58
|
+
* No callback is allowed to approve a human gate.
|
|
59
|
+
*/
|
|
60
|
+
export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, onEvent = () => {} }) {
|
|
61
|
+
validId(taskId, "task ID");
|
|
62
|
+
if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
|
|
63
|
+
for (const [name, value] of Object.entries({ acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, onEvent })) required(value, name);
|
|
64
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 1 || pollMs > 300000) throw new Error("invalid poll interval");
|
|
65
|
+
const ownership = await acquireSupervisor({ taskId, authority, signal });
|
|
66
|
+
if (!ownership || typeof ownership.assertOwned !== "function" || typeof ownership.release !== "function") throw new Error("supervisor ownership must provide assertOwned and release");
|
|
67
|
+
try {
|
|
68
|
+
let checkpoint = await loadCheckpoint({ taskId, authority });
|
|
69
|
+
if (checkpoint && (checkpoint.taskId !== taskId || checkpoint.authority !== authority)) throw new Error("checkpoint authority or task mismatch");
|
|
70
|
+
while (!signal?.aborted) {
|
|
71
|
+
const snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
72
|
+
let precondition;
|
|
73
|
+
if (checkpoint) {
|
|
74
|
+
const verdict = await checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval });
|
|
75
|
+
if (verdict.status === "blocked") return { status: "blocked", taskId, reason: verdict.reason };
|
|
76
|
+
if (verdict.status === "wait") {
|
|
77
|
+
try { await sleep(pollMs, undefined, { signal }); } catch (error) { if (error?.name !== "AbortError") throw error; }
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
precondition = verdict.precondition;
|
|
81
|
+
} else if (snapshot.nextAction.classification !== "run_phase") {
|
|
82
|
+
return { status: snapshot.nextAction.classification === "await_user" ? "blocked" : snapshot.nextAction.classification, taskId, reason: snapshot.nextAction.classification === "await_user" ? "gate checkpoint missing; inspect the gate before resuming" : snapshot.nextAction.reason };
|
|
83
|
+
}
|
|
84
|
+
// Leave the durable gate checkpoint intact until the next stop is saved. A
|
|
85
|
+
// crash during a turn then cannot cause an unverified automatic restart.
|
|
86
|
+
await ownership.assertOwned();
|
|
87
|
+
const result = await runTurn({ taskId, authority, precondition, signal });
|
|
88
|
+
if (result?.status !== "gate") {
|
|
89
|
+
if (checkpoint && ["complete", "terminal"].includes(result?.status)) await clearCheckpoint({ taskId, authority, checkpoint });
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
const fresh = validateSnapshot(await readTask({ taskId }), taskId);
|
|
93
|
+
const next = makeGateCheckpoint({ taskId, authority, snapshot: fresh, result });
|
|
94
|
+
await ownership.assertOwned();
|
|
95
|
+
await saveCheckpoint({ taskId, authority, checkpoint: next, previous: checkpoint });
|
|
96
|
+
checkpoint = next;
|
|
97
|
+
await onEvent({ type: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView });
|
|
98
|
+
}
|
|
99
|
+
return { status: "stopped", taskId };
|
|
100
|
+
} finally { await ownership.release(); }
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { sep } from 'node:path';
|
|
3
|
+
import { isLoopProvider } from './loop-drivers.mjs';
|
|
4
|
+
|
|
5
|
+
/** Container workers receive credentials as environment keys only. OpenCode
|
|
6
|
+
* authenticates from a file-based data store, so it fails closed here. */
|
|
7
|
+
export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
|
|
8
|
+
|
|
9
|
+
/** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
|
|
10
|
+
export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
|
|
11
|
+
const root = realpathSync(workspace);
|
|
12
|
+
if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
|
|
13
|
+
if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image)) throw new Error('Explicit Docker image is required');
|
|
14
|
+
if (command === 'opencode') throw new Error(OPENCODE_CONTAINER_ERROR);
|
|
15
|
+
if (!isLoopProvider(command) || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('Invalid worker provider command');
|
|
16
|
+
if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error('Non-root worker uid and gid are required');
|
|
17
|
+
if (!hostEnv || typeof hostEnv !== 'object' || !Array.isArray(credentialEnv) || !credentialEnv.length || credentialEnv.some((key) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof hostEnv[key] !== 'string' || !hostEnv[key])) throw new Error('Explicit worker provider credentials are required');
|
|
18
|
+
const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : ['ANTHROPIC_API_KEY'];
|
|
19
|
+
if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
|
|
20
|
+
if (containerName !== undefined && (typeof containerName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerName))) throw new Error('Invalid container name');
|
|
21
|
+
let homeMount;
|
|
22
|
+
if (sessionHome !== undefined) {
|
|
23
|
+
const info = lstatSync(sessionHome);
|
|
24
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== uid || (info.mode & 0o077)) throw new Error('Private worker session HOME is required');
|
|
25
|
+
const home = realpathSync(sessionHome);
|
|
26
|
+
if (/[\x00-\x1f,]/.test(home) || home === root || home.startsWith(`${root}${sep}`) || root.startsWith(`${home}${sep}`)) throw new Error('Worker session HOME must be separate from workspace');
|
|
27
|
+
homeMount = `type=bind,src=${home},dst=/home/worker`;
|
|
28
|
+
}
|
|
29
|
+
const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
|
|
30
|
+
for (const key of credentialEnv) env[key] = hostEnv[key];
|
|
31
|
+
return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), '--entrypoint', command, image, ...args], env };
|
|
32
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const excluded = (path) => path.split('/').includes('.git') || path === '.agents/data' || path.startsWith('.agents/data/');
|
|
6
|
+
const scopePath = (path) => typeof path === 'string' && path && !isAbsolute(path) && !path.includes('\\') && !path.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === '.agents');
|
|
7
|
+
const within = (path, scopes) => scopes.some((scope) => path === scope || path.startsWith(`${scope}/`));
|
|
8
|
+
const digest = (path) => createHash('sha256').update(readFile(path)).digest('hex');
|
|
9
|
+
import { readFileSync as readFile } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
// Reject links and special files rather than allowing a worker to make a host
|
|
12
|
+
// copy operation follow an external path or alias a file outside its scope.
|
|
13
|
+
function inventory(root, skipExcluded) {
|
|
14
|
+
const files = new Map();
|
|
15
|
+
function visit(dir, prefix = '') {
|
|
16
|
+
for (const name of readdirSync(dir)) {
|
|
17
|
+
const path = prefix ? `${prefix}/${name}` : name;
|
|
18
|
+
if (excluded(path)) {
|
|
19
|
+
if (skipExcluded) continue;
|
|
20
|
+
throw new Error(`Worker created a protected path: ${path}`);
|
|
21
|
+
}
|
|
22
|
+
const full = join(dir, name), info = lstatSync(full);
|
|
23
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()) || (info.isFile() && info.nlink !== 1)) throw new Error(`Unsafe worker file: ${path}`);
|
|
24
|
+
if (info.isDirectory()) visit(full, path);
|
|
25
|
+
else files.set(path, { digest: digest(full), executable: Boolean(info.mode & 0o111) });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
visit(root);
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Create a source-only snapshot. The caller supplies a new empty directory. */
|
|
33
|
+
export function createWorkerSnapshot(source, destination, trackedPaths) {
|
|
34
|
+
const src = realpathSync(source), dst = realpathSync(destination);
|
|
35
|
+
if (src === dst || dst.startsWith(`${src}${sep}`) || src.startsWith(`${dst}${sep}`)) throw new Error('Worker snapshot must be separate from source');
|
|
36
|
+
if (readdirSync(dst).length) throw new Error('Worker snapshot destination must be empty');
|
|
37
|
+
if (!Array.isArray(trackedPaths) || !trackedPaths.length || trackedPaths.some((path) => typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || excluded(path))) throw new Error('An explicit safe tracked-file manifest is required');
|
|
38
|
+
const sourceFiles = inventory(src, true), baseline = new Map();
|
|
39
|
+
for (const path of new Set(trackedPaths)) {
|
|
40
|
+
const entry = sourceFiles.get(path);
|
|
41
|
+
if (!entry) throw new Error(`Tracked worker source is missing or unsafe: ${path}`);
|
|
42
|
+
baseline.set(path, entry);
|
|
43
|
+
}
|
|
44
|
+
for (const [path] of baseline) {
|
|
45
|
+
const target = join(dst, path);
|
|
46
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
47
|
+
copyFileSync(join(src, path), target);
|
|
48
|
+
}
|
|
49
|
+
return baseline;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Validate the complete stopped-worker snapshot before host integration. */
|
|
53
|
+
export function inspectWorkerSnapshot(destination, baseline, scopes) {
|
|
54
|
+
const normalizedScopes = (Array.isArray(scopes) ? scopes : []).map((scope) => (typeof scope === "string" ? scope.replace(/\/+$/, "") : scope)).filter((scope) => scope);
|
|
55
|
+
if (!(baseline instanceof Map) || !normalizedScopes.length || !normalizedScopes.every(scopePath)) throw new Error('Invalid snapshot baseline or scopes');
|
|
56
|
+
const root = realpathSync(destination), current = inventory(root, false);
|
|
57
|
+
const changed = [];
|
|
58
|
+
for (const path of new Set([...baseline.keys(), ...current.keys()])) {
|
|
59
|
+
const before = baseline.get(path), after = current.get(path);
|
|
60
|
+
if (!before || !after || before.digest !== after.digest || before.executable !== after.executable) {
|
|
61
|
+
if (!scopePath(path) || !within(path, normalizedScopes)) throw new Error(`Worker changed a path outside its scope: ${path}`);
|
|
62
|
+
changed.push(path);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return changed.sort();
|
|
66
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const parseMessage = (body, contentType, id) => {
|
|
4
|
+
if (contentType.includes("text/event-stream")) {
|
|
5
|
+
for (const block of body.split(/\r?\n\r?\n/)) {
|
|
6
|
+
const data = block.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
|
|
7
|
+
if (!data || data === "[DONE]") continue;
|
|
8
|
+
try { const message = JSON.parse(data); if (message.id === id) return message; } catch { /* keep reading */ }
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const message = JSON.parse(body);
|
|
13
|
+
return Array.isArray(message) ? message.find((entry) => entry.id === id) : message;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const remoteConfig = (env = process.env) => {
|
|
17
|
+
const url = env.AIDLC_FLEET_URL;
|
|
18
|
+
const workspace = env.AIDLC_FLEET_WORKSPACE;
|
|
19
|
+
const token = env.AIDLC_FLEET_TOKEN;
|
|
20
|
+
const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
|
|
21
|
+
const leadEpoch = Number(env.AIDLC_FLEET_LEAD_EPOCH);
|
|
22
|
+
if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/") || !Number.isSafeInteger(leadEpoch) || leadEpoch < 1) {
|
|
23
|
+
throw new Error("Remote state requires AIDLC_FLEET_URL, WORKSPACE, TOKEN, absolute PROJECT_ROOT, and positive LEAD_EPOCH");
|
|
24
|
+
}
|
|
25
|
+
return { url, workspace, token, projectRoot, leadEpoch, leadOwnerToken: env.AIDLC_FLEET_LEAD_OWNER_TOKEN, integrationBranch: env.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const featureConfig = (env = process.env) => {
|
|
29
|
+
const url = env.AIDLC_FLEET_URL;
|
|
30
|
+
const workspace = env.AIDLC_FLEET_WORKSPACE;
|
|
31
|
+
const token = env.AIDLC_FLEET_TOKEN;
|
|
32
|
+
const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
|
|
33
|
+
if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/")) {
|
|
34
|
+
throw new Error("Remote feature commands require AIDLC_FLEET_URL, WORKSPACE, TOKEN, and absolute PROJECT_ROOT");
|
|
35
|
+
}
|
|
36
|
+
return { url, workspace, token, projectRoot };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const reviewerConfig = (env = process.env) => {
|
|
40
|
+
const url = env.AIDLC_FLEET_URL;
|
|
41
|
+
const workspace = env.AIDLC_FLEET_WORKSPACE;
|
|
42
|
+
const token = env.AIDLC_FLEET_REVIEWER_TOKEN;
|
|
43
|
+
const projectRoot = env.AIDLC_FLEET_PROJECT_ROOT;
|
|
44
|
+
if (!url || !/^https?:\/\//.test(url) || !workspace || !token || !projectRoot?.startsWith("/")) {
|
|
45
|
+
throw new Error("Remote review requires AIDLC_FLEET_URL, WORKSPACE, REVIEWER_TOKEN, and absolute PROJECT_ROOT");
|
|
46
|
+
}
|
|
47
|
+
return { url, workspace, token, projectRoot, integrationBranch: env.AIDLC_FLEET_INTEGRATION_BRANCH, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const callRemoteTool = async (config, name, args = {}, fetchImpl = fetch) => {
|
|
51
|
+
const id = randomUUID();
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetchImpl(config.url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { Authorization: `Bearer ${config.token}`, Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": "2025-06-18" },
|
|
57
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }),
|
|
58
|
+
signal: AbortSignal.timeout(20000)
|
|
59
|
+
});
|
|
60
|
+
} catch (error) { throw new Error(`Remote coordination unavailable: ${error.message}`); }
|
|
61
|
+
const body = await response.text();
|
|
62
|
+
if (!response.ok) throw new Error(`Remote coordination HTTP ${response.status}: ${body.slice(0, 300)}`);
|
|
63
|
+
let message;
|
|
64
|
+
try { message = parseMessage(body, response.headers.get("content-type") ?? "", id); }
|
|
65
|
+
catch { throw new Error("Remote coordination returned invalid JSON"); }
|
|
66
|
+
if (!message || message.id !== id) throw new Error("Remote coordination returned no matching result");
|
|
67
|
+
if (message.error) throw new Error(`Remote coordination RPC ${message.error.code}: ${message.error.message}`);
|
|
68
|
+
const tool = message.result;
|
|
69
|
+
if (!Array.isArray(tool?.content) || tool.content[0]?.type !== "text") throw new Error("Remote coordination returned no JSON text");
|
|
70
|
+
let value;
|
|
71
|
+
try { value = JSON.parse(tool.content[0].text); } catch { throw new Error("Remote coordination returned invalid tool JSON"); }
|
|
72
|
+
if (tool.isError) {
|
|
73
|
+
const error = new Error(value.message ?? value.code ?? "Remote coordination rejected operation");
|
|
74
|
+
error.code = value.code; error.current = value.current;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const createRemoteStateClient = (config, call = callRemoteTool) => ({
|
|
81
|
+
get: () => call(config, "state_get", { workspace: config.workspace }),
|
|
82
|
+
task: (taskId) => call(config, "task_get", { workspace: config.workspace, taskId, clientProjectRoot: config.projectRoot }),
|
|
83
|
+
apply: (expectedRevision, taskId, task) => call(config, "state_apply", {
|
|
84
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), clientProjectRoot: config.projectRoot,
|
|
85
|
+
leadEpoch: config.leadEpoch, ...(config.leadOwnerToken ? { leadOwnerToken: config.leadOwnerToken } : {}), command: { type: "upsertTask", taskId, task }
|
|
86
|
+
}),
|
|
87
|
+
snapshot: (expectedRevision, taskId, gate, reviewText, integrationCommit) => call(config, "gate_snapshot_put", {
|
|
88
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId,
|
|
89
|
+
leadEpoch: config.leadEpoch, ...(config.leadOwnerToken ? { leadOwnerToken: config.leadOwnerToken } : {}), gate, reviewText, ...(integrationCommit ? { integrationCommit } : {})
|
|
90
|
+
}),
|
|
91
|
+
featureGet: (featureId) => call(config, "feature_get", { workspace: config.workspace, featureId }),
|
|
92
|
+
featureReady: (featureId) => call(config, "feature_ready", { workspace: config.workspace, featureId }),
|
|
93
|
+
featureApply: (expectedRevision, feature) => call(config, "feature_apply", {
|
|
94
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), feature
|
|
95
|
+
}),
|
|
96
|
+
fleetGet: () => call(config, "fleet_get", { workspace: config.workspace }),
|
|
97
|
+
runClaim: (expectedRevision, taskId, leaseMs) => call(config, "run_claim", {
|
|
98
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, leaseMs
|
|
99
|
+
}),
|
|
100
|
+
runRelease: (expectedRevision, taskId, epoch) => call(config, "run_release", {
|
|
101
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, epoch
|
|
102
|
+
})
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const createRemoteReviewerClient = (config, call = callRemoteTool) => ({
|
|
106
|
+
get: () => call(config, "state_get", { workspace: config.workspace }),
|
|
107
|
+
inbox: () => call(config, "gate_inbox_get", { workspace: config.workspace }),
|
|
108
|
+
approve: (expectedRevision, taskId, snapshotId, source, integrationCommit) => call(config, "gate_approve", {
|
|
109
|
+
workspace: config.workspace, expectedRevision, idempotencyKey: randomUUID(), taskId, snapshotId,
|
|
110
|
+
source, clientProjectRoot: config.projectRoot, ...(integrationCommit ? { integrationCommit } : {})
|
|
111
|
+
})
|
|
112
|
+
});
|