@uuaid/agent-commons 0.0.0 → 0.2.0-alpha.4
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/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +359 -0
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
// Seven-slot collaboration scheduler.
|
|
2
|
+
//
|
|
3
|
+
// Provider-neutral: every external capability (model, persistence, memory,
|
|
4
|
+
// operational-needs intake, verification, artifact sink, clock) is injected
|
|
5
|
+
// through a small abstract interface. The scheduler never executes shell
|
|
6
|
+
// commands; builders may only emit *patch proposal artifacts* which the host
|
|
7
|
+
// decides whether to apply.
|
|
8
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { posix, join } from "node:path";
|
|
10
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
11
|
+
|
|
12
|
+
export const COLLABORATION_VERSION = 1;
|
|
13
|
+
export const ROSTER_SIZE = 7;
|
|
14
|
+
export const ROLES = Object.freeze(["needs", "planner", "builder", "tester", "reviewer"]);
|
|
15
|
+
export const PHASES = Object.freeze(["needs", "plan", "build", "test", "review"]);
|
|
16
|
+
export const TERMINAL_STATUSES = Object.freeze(["completed", "needs-host-verification", "cancelled", "failed"]);
|
|
17
|
+
export const MEMORY_FAILURE_POLICIES = Object.freeze(["halt", "best-effort"]);
|
|
18
|
+
const TASK_STATUSES = new Set(["pending", "in-progress", "done", "failed", "blocked"]);
|
|
19
|
+
const FATAL = new WeakMap(); // run state -> first fail-closed StopSignal (memory/persistence)
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_ROSTER = Object.freeze([
|
|
22
|
+
{ id: "needs-analyst", role: "needs", instructions: "Identify operational needs, constraints and acceptance criteria before any work starts." },
|
|
23
|
+
{ id: "planner", role: "planner", instructions: "Decompose the objective into small dependency-ordered tasks." },
|
|
24
|
+
{ id: "builder-a", role: "builder", instructions: "Produce minimal patch proposals for one task. Never propose commands." },
|
|
25
|
+
{ id: "builder-b", role: "builder", instructions: "Produce minimal patch proposals for one task. Never propose commands." },
|
|
26
|
+
{ id: "builder-c", role: "builder", instructions: "Produce minimal patch proposals for one task. Never propose commands." },
|
|
27
|
+
{ id: "tester", role: "tester", instructions: "Assess a patch proposal against acceptance criteria and report findings." },
|
|
28
|
+
{ id: "reviewer", role: "reviewer", instructions: "Peer-review a patch proposal; approve or request specific changes." },
|
|
29
|
+
].map(Object.freeze));
|
|
30
|
+
|
|
31
|
+
export const BASELINE_NEEDS = Object.freeze([
|
|
32
|
+
{ id: "objective", question: "What concrete outcome must this collaboration deliver?", required: true },
|
|
33
|
+
{ id: "acceptanceCriteria", question: "Which observable acceptance criteria decide that the outcome is done?", required: true },
|
|
34
|
+
{ id: "allowedPaths", question: "Which repository paths may patch proposals touch?", required: true },
|
|
35
|
+
{ id: "constraints", question: "Which technical, policy, privacy or compatibility constraints apply?", required: false },
|
|
36
|
+
{ id: "environment", question: "Which runtime, versions and test commands will the host use to verify proposals?", required: false },
|
|
37
|
+
{ id: "deadline", question: "Is there a deadline or budget beyond the configured limits?", required: false },
|
|
38
|
+
].map(Object.freeze));
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
41
|
+
maxRounds: 6,
|
|
42
|
+
maxTokens: 200_000,
|
|
43
|
+
maxTokensPerCall: 8_000,
|
|
44
|
+
maxDurationMs: 15 * 60_000,
|
|
45
|
+
maxConcurrency: 3,
|
|
46
|
+
maxTasks: 24,
|
|
47
|
+
maxAttemptsPerTask: 3,
|
|
48
|
+
maxPatchBytes: 256 * 1024,
|
|
49
|
+
maxFilesPerProposal: 32,
|
|
50
|
+
maxEvents: 500,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const FORBIDDEN_KEYS = new Set(["command", "commands", "cmd", "shell", "exec", "execute", "script", "scripts", "run", "spawn", "eval"]);
|
|
54
|
+
|
|
55
|
+
export class CollaborationError extends Error {
|
|
56
|
+
constructor(code, message, details) { super(message); this.name = "CollaborationError"; this.code = code; if (details !== undefined) this.details = details; }
|
|
57
|
+
}
|
|
58
|
+
class StopSignal extends Error {
|
|
59
|
+
constructor(reason, message) { super(message || reason); this.name = "StopSignal"; this.reason = reason; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------- utilities
|
|
63
|
+
export function stableStringify(value) {
|
|
64
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
65
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
66
|
+
return `{${Object.keys(value).filter(k => value[k] !== undefined).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
67
|
+
}
|
|
68
|
+
export function hashOf(value) { return createHash("sha256").update(stableStringify(value)).digest("hex"); }
|
|
69
|
+
const clone = value => (value === undefined ? undefined : structuredClone(value));
|
|
70
|
+
const estimateTokens = value => Math.ceil((typeof value === "string" ? value : stableStringify(value ?? "")).length / 4);
|
|
71
|
+
const normalizeTitle = title => String(title || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
72
|
+
const isPositiveInt = n => Number.isInteger(n) && n > 0;
|
|
73
|
+
|
|
74
|
+
function createSemaphore(limit) {
|
|
75
|
+
let active = 0;
|
|
76
|
+
const queue = [];
|
|
77
|
+
const next = () => { if (active < limit && queue.length) { active++; queue.shift()(); } };
|
|
78
|
+
return {
|
|
79
|
+
get active() { return active; },
|
|
80
|
+
async run(fn) {
|
|
81
|
+
await new Promise(resolve => { queue.push(resolve); next(); });
|
|
82
|
+
try { return await fn(); } finally { active--; next(); }
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function abortable(promise, signal) {
|
|
88
|
+
if (!signal) return promise;
|
|
89
|
+
if (signal.aborted) return Promise.reject(new StopSignal("cancelled", String(signal.reason ?? "cancelled")));
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const onAbort = () => reject(new StopSignal("cancelled", String(signal.reason ?? "cancelled")));
|
|
92
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
promise.then(v => { signal.removeEventListener("abort", onAbort); resolve(v); },
|
|
94
|
+
e => { signal.removeEventListener("abort", onAbort); reject(e); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Parse provider-neutral model output: `{output}` object, or JSON (optionally fenced) in `{content|text}`. */
|
|
99
|
+
export function parseModelOutput(result) {
|
|
100
|
+
if (result && typeof result === "object" && result.output && typeof result.output === "object") return result.output;
|
|
101
|
+
const text = typeof result === "string" ? result : (result?.content ?? result?.text);
|
|
102
|
+
if (text && typeof text === "object") return text;
|
|
103
|
+
if (typeof text !== "string") throw new CollaborationError("invalid-output", "Model adapter returned no content");
|
|
104
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
105
|
+
const candidate = (fenced ? fenced[1] : text).trim();
|
|
106
|
+
try { return JSON.parse(candidate); } catch {
|
|
107
|
+
const start = candidate.indexOf("{"), end = candidate.lastIndexOf("}");
|
|
108
|
+
if (start >= 0 && end > start) { try { return JSON.parse(candidate.slice(start, end + 1)); } catch { /* fall through */ } }
|
|
109
|
+
throw new CollaborationError("invalid-output", "Model output is not JSON");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- roster
|
|
114
|
+
export function createRoster(overrides) {
|
|
115
|
+
let roster;
|
|
116
|
+
if (overrides === undefined) roster = DEFAULT_ROSTER.map(s => ({ ...s }));
|
|
117
|
+
else if (Array.isArray(overrides)) roster = overrides.map(s => ({ ...s }));
|
|
118
|
+
else if (overrides && typeof overrides === "object") {
|
|
119
|
+
// Keyed overrides: { "builder-c": { role: "reviewer", model: "x" } }
|
|
120
|
+
roster = DEFAULT_ROSTER.map(s => ({ ...s, ...(overrides[s.id] || {}), id: overrides[s.id]?.id ?? s.id }));
|
|
121
|
+
for (const key of Object.keys(overrides)) if (!DEFAULT_ROSTER.some(s => s.id === key)) throw new CollaborationError("invalid-roster", `Unknown roster slot ${key}`);
|
|
122
|
+
} else throw new CollaborationError("invalid-roster", "Roster must be an array or slot-keyed object");
|
|
123
|
+
if (roster.length !== ROSTER_SIZE) throw new CollaborationError("invalid-roster", `Roster must contain exactly ${ROSTER_SIZE} slots`);
|
|
124
|
+
const ids = new Set();
|
|
125
|
+
for (const slot of roster) {
|
|
126
|
+
if (typeof slot.id !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(slot.id)) throw new CollaborationError("invalid-roster", `Invalid slot id ${slot.id}`);
|
|
127
|
+
if (ids.has(slot.id)) throw new CollaborationError("invalid-roster", `Duplicate slot id ${slot.id}`);
|
|
128
|
+
if (!ROLES.includes(slot.role)) throw new CollaborationError("invalid-roster", `Invalid role ${slot.role} for ${slot.id}`);
|
|
129
|
+
ids.add(slot.id);
|
|
130
|
+
}
|
|
131
|
+
for (const role of ROLES) if (!roster.some(s => s.role === role)) throw new CollaborationError("invalid-roster", `Roster is missing a ${role} slot`);
|
|
132
|
+
return Object.freeze(roster.map(Object.freeze));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------- persistence / memory defaults
|
|
136
|
+
export function createMemoryPersistence() {
|
|
137
|
+
const runs = new Map();
|
|
138
|
+
return {
|
|
139
|
+
async load(runId) { return clone(runs.get(runId)) ?? null; },
|
|
140
|
+
async save(runId, state) { runs.set(runId, clone(state)); },
|
|
141
|
+
_runs: runs,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export const STATE_FORMAT = "agent-commons/collaboration-state/1";
|
|
145
|
+
/**
|
|
146
|
+
* JSON-file persistence (one envelope per run, atomic rename) for CLI continuity across processes.
|
|
147
|
+
* Every envelope carries a sha256 of the serialized state, which detects accidental corruption only.
|
|
148
|
+
* Tamper resistance requires an injected `integrity` signer: `{ sign(bytes) -> string, verify(bytes, signature) -> boolean }`
|
|
149
|
+
* (sync or async; e.g. `hmacIntegrity(secret)` or a host Keychain). With `integrity`, unsigned or
|
|
150
|
+
* mis-signed envelopes fail closed on load.
|
|
151
|
+
*/
|
|
152
|
+
export function createFilePersistence(directory, { integrity = null } = {}) {
|
|
153
|
+
if (typeof directory !== "string" || !directory) throw new CollaborationError("invalid-config", "directory is required");
|
|
154
|
+
if (integrity && (typeof integrity.sign !== "function" || typeof integrity.verify !== "function")) throw new CollaborationError("invalid-config", "integrity must implement sign(bytes) and verify(bytes, signature)");
|
|
155
|
+
const file = runId => join(directory, `${createHash("sha256").update(String(runId)).digest("hex").slice(0, 32)}.json`);
|
|
156
|
+
const signed = sha256 => Buffer.from(`${STATE_FORMAT}\n${sha256}`);
|
|
157
|
+
const corrupt = why => new CollaborationError("persistence-corrupt", `State file rejected: ${why}`);
|
|
158
|
+
return {
|
|
159
|
+
integrity: integrity ? "signed" : "checksum-only",
|
|
160
|
+
async load(runId) {
|
|
161
|
+
let text;
|
|
162
|
+
try { text = await readFile(file(runId), "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
163
|
+
let envelope;
|
|
164
|
+
try { envelope = JSON.parse(text); } catch { throw corrupt("not JSON"); }
|
|
165
|
+
if (!plainObject(envelope) || envelope.format !== STATE_FORMAT || typeof envelope.state !== "string" || typeof envelope.sha256 !== "string") throw corrupt("unknown envelope");
|
|
166
|
+
if (createHash("sha256").update(envelope.state).digest("hex") !== envelope.sha256) throw corrupt("checksum mismatch");
|
|
167
|
+
if (integrity) {
|
|
168
|
+
if (typeof envelope.signature !== "string" || !envelope.signature) throw corrupt("missing signature");
|
|
169
|
+
let ok = false;
|
|
170
|
+
try { ok = (await integrity.verify(signed(envelope.sha256), envelope.signature)) === true; } catch { ok = false; }
|
|
171
|
+
if (!ok) throw corrupt("signature invalid");
|
|
172
|
+
}
|
|
173
|
+
try { return JSON.parse(envelope.state); } catch { throw corrupt("state not JSON"); }
|
|
174
|
+
},
|
|
175
|
+
async save(runId, state) {
|
|
176
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
177
|
+
const body = JSON.stringify(state);
|
|
178
|
+
const sha256 = createHash("sha256").update(body).digest("hex");
|
|
179
|
+
const signature = integrity ? await integrity.sign(signed(sha256)) : null;
|
|
180
|
+
if (integrity && (typeof signature !== "string" || !signature)) throw new CollaborationError("persistence-failure", "integrity.sign returned no signature");
|
|
181
|
+
const tmp = `${file(runId)}.${process.pid}.${Date.now()}.tmp`;
|
|
182
|
+
await writeFile(tmp, JSON.stringify({ format: STATE_FORMAT, sha256, signature, state: body }), { mode: 0o600 });
|
|
183
|
+
await rename(tmp, file(runId));
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/** Local HMAC-SHA256 integrity signer for createFilePersistence. Keep the secret outside model inputs and state. */
|
|
188
|
+
export function hmacIntegrity(secret) {
|
|
189
|
+
if (!(typeof secret === "string" || Buffer.isBuffer(secret)) || Buffer.byteLength(secret) < 32) throw new CollaborationError("invalid-config", "HMAC secret must be at least 32 bytes");
|
|
190
|
+
const mac = bytes => createHmac("sha256", secret).update(bytes).digest("hex");
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
sign: bytes => mac(bytes),
|
|
193
|
+
verify: (bytes, signature) => { const expected = Buffer.from(mac(bytes)); const got = Buffer.from(String(signature)); return got.length === expected.length && timingSafeEqual(got, expected); },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
export const NOOP_MEMORY = Object.freeze({ async recall() { return []; }, async record() {} });
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------- tasks
|
|
199
|
+
export function normalizePlan(rawTasks, { maxTasks = DEFAULT_LIMITS.maxTasks } = {}) {
|
|
200
|
+
if (!Array.isArray(rawTasks) || !rawTasks.length) throw new CollaborationError("invalid-plan", "Plan must contain at least one task");
|
|
201
|
+
const byId = new Map(), byTitle = new Map(), aliases = new Map();
|
|
202
|
+
for (const [index, raw] of rawTasks.entries()) {
|
|
203
|
+
if (!raw || typeof raw !== "object") throw new CollaborationError("invalid-plan", `Task ${index} is not an object`);
|
|
204
|
+
const title = String(raw.title || raw.id || "").trim();
|
|
205
|
+
if (!title) throw new CollaborationError("invalid-plan", `Task ${index} has no title`);
|
|
206
|
+
const id = String(raw.id || `t${index + 1}`).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 64);
|
|
207
|
+
const deps = Array.isArray(raw.dependsOn) ? raw.dependsOn.map(d => String(d).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-")) : [];
|
|
208
|
+
const existing = byId.get(id) || byTitle.get(normalizeTitle(title));
|
|
209
|
+
if (existing) { // dedup: merge duplicates by id or title
|
|
210
|
+
existing.dependsOn = [...new Set([...existing.dependsOn, ...deps])];
|
|
211
|
+
if (raw.id) aliases.set(String(raw.id).toLowerCase(), existing.id);
|
|
212
|
+
if (id !== existing.id) aliases.set(id, existing.id);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const task = { id, title, description: String(raw.description || ""), dependsOn: [...new Set(deps)], status: "pending", attempts: 0, feedback: [], assignee: null, proposals: [] };
|
|
216
|
+
byId.set(id, task); byTitle.set(normalizeTitle(title), task);
|
|
217
|
+
}
|
|
218
|
+
if (byId.size > maxTasks) throw new CollaborationError("invalid-plan", `Plan has ${byId.size} tasks; limit is ${maxTasks}`);
|
|
219
|
+
for (const task of byId.values()) {
|
|
220
|
+
task.dependsOn = [...new Set(task.dependsOn.map(d => aliases.get(d) || d))];
|
|
221
|
+
if (task.dependsOn.includes(task.id)) throw new CollaborationError("invalid-plan", `Task ${task.id} depends on itself`);
|
|
222
|
+
for (const dep of task.dependsOn) if (!byId.has(dep)) throw new CollaborationError("invalid-plan", `Task ${task.id} depends on unknown task ${dep}`);
|
|
223
|
+
}
|
|
224
|
+
// Kahn cycle detection
|
|
225
|
+
const indegree = new Map([...byId.keys()].map(id => [id, byId.get(id).dependsOn.length]));
|
|
226
|
+
const queue = [...indegree].filter(([, n]) => n === 0).map(([id]) => id);
|
|
227
|
+
let seen = 0;
|
|
228
|
+
while (queue.length) {
|
|
229
|
+
const id = queue.shift(); seen++;
|
|
230
|
+
for (const t of byId.values()) if (t.dependsOn.includes(id)) { indegree.set(t.id, indegree.get(t.id) - 1); if (indegree.get(t.id) === 0) queue.push(t.id); }
|
|
231
|
+
}
|
|
232
|
+
if (seen !== byId.size) throw new CollaborationError("invalid-plan", "Plan contains a dependency cycle");
|
|
233
|
+
return Object.fromEntries(byId);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function readyTasks(tasks) {
|
|
237
|
+
return Object.values(tasks).filter(t => t.status === "pending" && t.dependsOn.every(d => tasks[d]?.status === "done"));
|
|
238
|
+
}
|
|
239
|
+
function propagateBlocked(tasks) {
|
|
240
|
+
let changed = true;
|
|
241
|
+
while (changed) {
|
|
242
|
+
changed = false;
|
|
243
|
+
for (const t of Object.values(tasks)) if (t.status === "pending" && t.dependsOn.some(d => ["failed", "blocked"].includes(tasks[d]?.status))) { t.status = "blocked"; changed = true; }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------- patch proposals
|
|
248
|
+
function findForbiddenKey(value, path = "") {
|
|
249
|
+
if (!value || typeof value !== "object") return null;
|
|
250
|
+
for (const [k, v] of Object.entries(value)) {
|
|
251
|
+
if (FORBIDDEN_KEYS.has(k.toLowerCase())) return `${path}${k}`;
|
|
252
|
+
const nested = findForbiddenKey(v, `${path}${k}.`);
|
|
253
|
+
if (nested) return nested;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
// Host-protected paths. Defaults are immutable; hosts may add rules but never remove them.
|
|
258
|
+
// Secrets are never writable. The protected code surface is writable only through an explicit
|
|
259
|
+
// constructor-level `hostMaintenance` authorization, which never comes from model or needs input.
|
|
260
|
+
export const SECRET_PATH_RULES = Object.freeze([
|
|
261
|
+
/(^|\/)\.env(\.[^/]*)?$/i, /(^|\/)\.npmrc$/i, /(^|\/)\.yarnrc(\.yml)?$/i, /(^|\/)\.pypirc$/i, /(^|\/)\.netrc$/i,
|
|
262
|
+
/(^|\/)identity\.json$/i, /(^|\/)local-secret[^/]*$/i, /(^|\/)[^/]*\.(pem|key|p12|pfx)$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i,
|
|
263
|
+
/(^|\/)[^/]*credentials?[^/]*$/i, /(^|\/)[^/]*secrets?(\.[^/]*)?$/i,
|
|
264
|
+
]);
|
|
265
|
+
export const PROTECTED_SURFACE_RULES = Object.freeze([
|
|
266
|
+
/(^|\/)\.github(\/|$)/i,
|
|
267
|
+
/(^|\/)(package|package-lock|npm-shrinkwrap)\.json$/i, /(^|\/)PROVENANCE\.json$/i,
|
|
268
|
+
/(^|\/)[^/]*(release|polic(y|ies)|trust|admission|budget|governance|provenance)[^/]*(\/|$)/i,
|
|
269
|
+
/(^|\/)collaboration\.mjs$/i,
|
|
270
|
+
]);
|
|
271
|
+
const underPrefix = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`); // slash-bounded, never bare startsWith
|
|
272
|
+
|
|
273
|
+
function safeRelative(path, label = "patch path") {
|
|
274
|
+
if (typeof path !== "string" || !path || path.length > 512 || path.includes("\0") || path.includes("\\") || /^[a-z]:/i.test(path) || path.startsWith("/") || path.startsWith("~") || /[\x00-\x1f]/.test(path))
|
|
275
|
+
throw new CollaborationError("invalid-proposal", `Unsafe ${label} ${JSON.stringify(path)}`);
|
|
276
|
+
const normalized = posix.normalize(path).replace(/\/+$/, "");
|
|
277
|
+
const segments = normalized.split("/");
|
|
278
|
+
if (!normalized || normalized === "." || segments.includes("..") || segments.includes(".") || segments.includes(".git"))
|
|
279
|
+
throw new CollaborationError("invalid-proposal", `Unsafe ${label} ${JSON.stringify(path)}`);
|
|
280
|
+
return normalized;
|
|
281
|
+
}
|
|
282
|
+
export const isSecretPath = path => SECRET_PATH_RULES.some(rule => rule.test(path));
|
|
283
|
+
export function isProtectedPath(path, extraProtected = []) {
|
|
284
|
+
return PROTECTED_SURFACE_RULES.some(rule => rule.test(path)) || extraProtected.some(prefix => underPrefix(path, prefix));
|
|
285
|
+
}
|
|
286
|
+
/** Normalize an allowlist. Empty, root ("."), unsafe, or secret entries are refused (fail closed). */
|
|
287
|
+
export function normalizeAllowedPaths(value) {
|
|
288
|
+
const list = (Array.isArray(value) ? value : typeof value === "string" ? value.split(/[\s,]+/) : []).map(v => String(v).trim()).filter(Boolean);
|
|
289
|
+
const out = [...new Set(list.map(entry => {
|
|
290
|
+
const normalized = safeRelative(entry, "allowed path");
|
|
291
|
+
if (isSecretPath(normalized)) throw new CollaborationError("invalid-proposal", `Allowed path ${normalized} names a secret`);
|
|
292
|
+
return normalized;
|
|
293
|
+
}))];
|
|
294
|
+
if (!out.length) throw new CollaborationError("invalid-proposal", "allowedPaths must list at least one explicit relative path");
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* @param {string} path
|
|
299
|
+
* @param {string[]} allowedPaths non-empty allowlist (from operational needs)
|
|
300
|
+
* @param {{protectedPaths?:string[], maintenancePaths?:string[]}} [policy] host-only configuration
|
|
301
|
+
*/
|
|
302
|
+
export function validatePatchPath(path, allowedPaths, { protectedPaths = [], maintenancePaths = [] } = {}) {
|
|
303
|
+
const normalized = safeRelative(path);
|
|
304
|
+
if (!Array.isArray(allowedPaths) || !allowedPaths.length) throw new CollaborationError("invalid-proposal", "No allowed paths configured; refusing all patches");
|
|
305
|
+
if (isSecretPath(normalized)) throw new CollaborationError("protected-path", `Patch path ${normalized} is a protected secret`);
|
|
306
|
+
if (isProtectedPath(normalized, protectedPaths) && !maintenancePaths.some(prefix => underPrefix(normalized, prefix)))
|
|
307
|
+
throw new CollaborationError("protected-path", `Patch path ${normalized} is host-protected; requires host maintenance authorization`);
|
|
308
|
+
if (!allowedPaths.some(prefix => underPrefix(normalized, prefix)))
|
|
309
|
+
throw new CollaborationError("invalid-proposal", `Patch path ${normalized} is outside allowed paths`);
|
|
310
|
+
return normalized;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Strict unified-diff check: exactly one file, headers must name `path`, hunks must be well-formed and complete. */
|
|
314
|
+
export function validateUnifiedDiff(path, diff) {
|
|
315
|
+
const bad = why => { throw new CollaborationError("invalid-proposal", `Diff for ${path} rejected: ${why}`); };
|
|
316
|
+
const target = (raw, side) => {
|
|
317
|
+
const name = raw.split("\t")[0].trim();
|
|
318
|
+
if (name === "/dev/null") bad("modify diffs may not create or delete files");
|
|
319
|
+
const stripped = name.startsWith(`${side}/`) ? name.slice(2) : name;
|
|
320
|
+
if (stripped !== path) bad(`header targets ${JSON.stringify(stripped)}, not the declared path`);
|
|
321
|
+
};
|
|
322
|
+
const lines = diff.replace(/\r\n/g, "\n").split("\n");
|
|
323
|
+
if (lines.at(-1) === "") lines.pop();
|
|
324
|
+
let minus = 0, plus = 0, hunks = 0, oldLeft = 0, newLeft = 0, changed = 0;
|
|
325
|
+
for (const line of lines) {
|
|
326
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
327
|
+
const tag = line[0];
|
|
328
|
+
if (tag === " ") { oldLeft--; newLeft--; }
|
|
329
|
+
else if (tag === "-") { oldLeft--; changed++; }
|
|
330
|
+
else if (tag === "+") { newLeft--; changed++; }
|
|
331
|
+
else if (tag === "\\") continue;
|
|
332
|
+
else if (line === "") { oldLeft--; newLeft--; }
|
|
333
|
+
else bad("malformed hunk line");
|
|
334
|
+
if (oldLeft < 0 || newLeft < 0) bad("hunk line counts exceeded");
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (line.startsWith("\\")) continue;
|
|
338
|
+
if (line.startsWith("diff --git ")) { if (minus || plus || hunks) bad("multiple files in one diff"); const m = line.match(/^diff --git a\/(\S+) b\/(\S+)$/); if (!m || m[1] !== path || m[2] !== path) bad("diff --git header does not match declared path"); continue; }
|
|
339
|
+
if (/^index [0-9a-f]+\.\.[0-9a-f]+( [0-7]{6})?$/.test(line)) { if (/ 120000$/.test(line)) bad("symlinks are not allowed"); continue; }
|
|
340
|
+
if (/^(similarity|dissimilarity|rename |copy |old mode|new mode|deleted file mode|new file mode|GIT binary patch|Binary files)/.test(line)) bad(`unsupported header ${JSON.stringify(line.slice(0, 40))}`);
|
|
341
|
+
if (line.startsWith("--- ")) { if (minus || hunks) bad("multiple files in one diff"); minus++; target(line.slice(4), "a"); continue; }
|
|
342
|
+
if (line.startsWith("+++ ")) { if (plus || !minus || hunks) bad("misordered file headers"); plus++; target(line.slice(4), "b"); continue; }
|
|
343
|
+
const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
344
|
+
if (hunk) { if (!plus) bad("hunk before file headers"); hunks++; oldLeft = hunk[2] === undefined ? 1 : Number(hunk[2]); newLeft = hunk[4] === undefined ? 1 : Number(hunk[4]); continue; }
|
|
345
|
+
bad(`unexpected line outside hunk ${JSON.stringify(line.slice(0, 40))}`);
|
|
346
|
+
}
|
|
347
|
+
if (minus !== 1 || plus !== 1) bad("exactly one ---/+++ header pair is required");
|
|
348
|
+
if (!hunks) bad("at least one hunk is required");
|
|
349
|
+
if (oldLeft > 0 || newLeft > 0) bad("truncated hunk");
|
|
350
|
+
if (!changed) bad("diff changes nothing");
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Validate a builder's output into an inert patch-proposal payload. Commands are never accepted. */
|
|
355
|
+
export function validateProposal(raw, { allowedPaths = [], protectedPaths = [], maintenancePaths = [], maxPatchBytes = DEFAULT_LIMITS.maxPatchBytes, maxFilesPerProposal = DEFAULT_LIMITS.maxFilesPerProposal } = {}) {
|
|
356
|
+
if (!raw || typeof raw !== "object") throw new CollaborationError("invalid-proposal", "Proposal must be an object");
|
|
357
|
+
const forbidden = findForbiddenKey(raw);
|
|
358
|
+
if (forbidden) throw new CollaborationError("forbidden-execution", `Proposals may not contain executable field ${forbidden}; emit patches only`);
|
|
359
|
+
const patches = raw.patches;
|
|
360
|
+
if (!Array.isArray(patches) || !patches.length) throw new CollaborationError("invalid-proposal", "Proposal must contain patches");
|
|
361
|
+
if (patches.length > maxFilesPerProposal) throw new CollaborationError("invalid-proposal", "Proposal touches too many files");
|
|
362
|
+
let bytes = 0;
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
const clean = patches.map(p => {
|
|
365
|
+
if (!p || typeof p !== "object") throw new CollaborationError("invalid-proposal", "Patch must be an object");
|
|
366
|
+
const op = p.op ?? (p.diff !== undefined ? "modify" : "create");
|
|
367
|
+
if (!["create", "modify", "delete"].includes(op)) throw new CollaborationError("invalid-proposal", `Invalid patch op ${op}`);
|
|
368
|
+
const path = validatePatchPath(p.path, allowedPaths, { protectedPaths, maintenancePaths });
|
|
369
|
+
if (seen.has(path)) throw new CollaborationError("invalid-proposal", `Duplicate patch path ${path}`);
|
|
370
|
+
seen.add(path);
|
|
371
|
+
const out = { path, op };
|
|
372
|
+
if (op === "modify") {
|
|
373
|
+
if (typeof p.diff !== "string" || !p.diff) throw new CollaborationError("invalid-proposal", `modify ${path} requires a unified diff`);
|
|
374
|
+
if (p.content !== undefined) throw new CollaborationError("invalid-proposal", `modify ${path} may not also carry content`);
|
|
375
|
+
validateUnifiedDiff(path, p.diff);
|
|
376
|
+
out.diff = p.diff;
|
|
377
|
+
}
|
|
378
|
+
if (op === "create") {
|
|
379
|
+
if (typeof p.content !== "string") throw new CollaborationError("invalid-proposal", `create ${path} requires content`);
|
|
380
|
+
if (p.diff !== undefined) throw new CollaborationError("invalid-proposal", `create ${path} may not also carry a diff`);
|
|
381
|
+
out.content = p.content;
|
|
382
|
+
}
|
|
383
|
+
if (op === "delete" && (p.diff !== undefined || p.content !== undefined)) throw new CollaborationError("invalid-proposal", `delete ${path} carries no payload`);
|
|
384
|
+
bytes += Buffer.byteLength(out.diff ?? out.content ?? "");
|
|
385
|
+
return out;
|
|
386
|
+
}).sort((a, b) => a.path.localeCompare(b.path));
|
|
387
|
+
if (bytes > maxPatchBytes) throw new CollaborationError("invalid-proposal", `Proposal is ${bytes} bytes; limit is ${maxPatchBytes}`);
|
|
388
|
+
return { summary: String(raw.summary || "").slice(0, 2000), patches: clean, bytes };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------- scheduler
|
|
392
|
+
/**
|
|
393
|
+
* @param {object} options
|
|
394
|
+
* @param {{complete(request):Promise<{content?:string|object, output?:object, usage?:{inputTokens?:number,outputTokens?:number,totalTokens?:number}}>}} options.model
|
|
395
|
+
* @param {{load(runId):Promise<object|null>, save(runId,state):Promise<void>}} [options.persistence]
|
|
396
|
+
* @param {{recall(query):Promise<Array>, record(entry):Promise<void>}} [options.memory]
|
|
397
|
+
* @param {{ask({runId,questions,missing,signal}):Promise<object>}} [options.needs]
|
|
398
|
+
* @param {{verify(artifact,{task,signal}):Promise<{passed:boolean,findings?:string[]}>}} [options.verifier]
|
|
399
|
+
* @param {{put(artifact):Promise<void>}} [options.artifacts]
|
|
400
|
+
*/
|
|
401
|
+
export class CollaborationScheduler {
|
|
402
|
+
constructor({ model, persistence = createMemoryPersistence(), memory = NOOP_MEMORY, memoryFailurePolicy = "halt", allowAdvisory = false, protectedPaths = [], hostMaintenance = null, needs = null, verifier = null, artifacts = null, roster, limits = {}, clock = { now: () => Date.now() }, onEvent = () => {}, signal } = {}) {
|
|
403
|
+
if (!model || typeof model.complete !== "function") throw new CollaborationError("invalid-config", "An injected model adapter with complete(request) is required");
|
|
404
|
+
if (!persistence || typeof persistence.load !== "function" || typeof persistence.save !== "function") throw new CollaborationError("invalid-config", "Persistence must implement load(runId) and save(runId, state)");
|
|
405
|
+
if (typeof memory?.recall !== "function" || typeof memory?.record !== "function") throw new CollaborationError("invalid-config", "Memory hooks must implement recall() and record()");
|
|
406
|
+
if (!MEMORY_FAILURE_POLICIES.includes(memoryFailurePolicy)) throw new CollaborationError("invalid-config", `memoryFailurePolicy must be one of ${MEMORY_FAILURE_POLICIES.join(", ")}`);
|
|
407
|
+
// Real (non-default) memory fails closed unless best-effort is chosen explicitly.
|
|
408
|
+
this.memoryFailurePolicy = memoryFailurePolicy;
|
|
409
|
+
this.memoryStrict = memory !== NOOP_MEMORY && memoryFailurePolicy === "halt";
|
|
410
|
+
if (needs && typeof needs.ask !== "function") throw new CollaborationError("invalid-config", "Needs provider must implement ask()");
|
|
411
|
+
if (verifier && typeof verifier.verify !== "function") throw new CollaborationError("invalid-config", "Verifier must implement verify()");
|
|
412
|
+
if (artifacts && typeof artifacts.put !== "function") throw new CollaborationError("invalid-config", "Artifact sink must implement put()");
|
|
413
|
+
if (typeof allowAdvisory !== "boolean") throw new CollaborationError("invalid-config", "allowAdvisory must be boolean");
|
|
414
|
+
this.allowAdvisory = allowAdvisory;
|
|
415
|
+
if (!Array.isArray(protectedPaths)) throw new CollaborationError("invalid-config", "protectedPaths must be an array");
|
|
416
|
+
this.protectedPaths = Object.freeze(protectedPaths.map(p => safeRelative(p, "protected path")));
|
|
417
|
+
if (hostMaintenance !== null) {
|
|
418
|
+
if (!plainObject(hostMaintenance) || typeof hostMaintenance.reason !== "string" || !hostMaintenance.reason.trim() || !Array.isArray(hostMaintenance.authorizedPaths) || !hostMaintenance.authorizedPaths.length)
|
|
419
|
+
throw new CollaborationError("invalid-config", "hostMaintenance requires a reason and non-empty authorizedPaths");
|
|
420
|
+
}
|
|
421
|
+
this.maintenancePaths = Object.freeze((hostMaintenance?.authorizedPaths ?? []).map(p => {
|
|
422
|
+
const n = safeRelative(p, "maintenance path");
|
|
423
|
+
if (isSecretPath(n)) throw new CollaborationError("invalid-config", `Secrets cannot be authorized for maintenance: ${n}`);
|
|
424
|
+
return n;
|
|
425
|
+
}));
|
|
426
|
+
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
427
|
+
for (const [k, v] of Object.entries(this.limits)) if (!isPositiveInt(v)) throw new CollaborationError("invalid-config", `Limit ${k} must be a positive integer`);
|
|
428
|
+
this.roster = createRoster(roster);
|
|
429
|
+
Object.assign(this, { model, persistence, memory, needsProvider: needs, verifier, artifactSink: artifacts, clock, onEvent });
|
|
430
|
+
this.controller = new AbortController();
|
|
431
|
+
if (signal) { if (signal.aborted) this.controller.abort(signal.reason); else signal.addEventListener("abort", () => this.controller.abort(signal.reason), { once: true }); }
|
|
432
|
+
this.semaphore = createSemaphore(this.limits.maxConcurrency);
|
|
433
|
+
this.inflight = new Map(); // idempotency key -> promise (call dedup)
|
|
434
|
+
this.runs = new Map(); // runId -> promise (run dedup)
|
|
435
|
+
this.saveChain = Promise.resolve();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
slots(role) { return this.roster.filter(s => s.role === role); }
|
|
439
|
+
cancel(reason = "cancelled") { this.controller.abort(reason); }
|
|
440
|
+
get signal() { return this.controller.signal; }
|
|
441
|
+
|
|
442
|
+
/** Start or resume a run. Concurrent calls for the same runId share one execution. */
|
|
443
|
+
run(input = {}) {
|
|
444
|
+
const runId = String(input.runId || "");
|
|
445
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(runId)) return Promise.reject(new CollaborationError("invalid-run", "runId is required (1-128 safe characters)"));
|
|
446
|
+
if (this.runs.has(runId)) return this.runs.get(runId);
|
|
447
|
+
const promise = this.#execute(runId, input).finally(() => this.runs.delete(runId));
|
|
448
|
+
this.runs.set(runId, promise);
|
|
449
|
+
return promise;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Merge operational-need answers into a persisted run (e.g. after status "awaiting-needs"). */
|
|
453
|
+
async provideNeeds(runId, answers) {
|
|
454
|
+
const state = await this.persistence.load(runId);
|
|
455
|
+
if (!state) throw new CollaborationError("unknown-run", `No run ${runId}`);
|
|
456
|
+
validateRunState(state, runId);
|
|
457
|
+
state.needs.answers = { ...state.needs.answers, ...sanitizeAnswers(answers) };
|
|
458
|
+
if (state.status === "awaiting-needs") state.status = "pending";
|
|
459
|
+
await this.#save(state);
|
|
460
|
+
return this.#result(state);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async status(runId) { const state = await this.persistence.load(runId); if (state) validateRunState(state, runId); return state ? this.#result(state) : null; }
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------- internals
|
|
466
|
+
async #execute(runId, input) {
|
|
467
|
+
let state;
|
|
468
|
+
try {
|
|
469
|
+
state = await this.persistence.load(runId);
|
|
470
|
+
if (state) validateRunState(state, runId);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
// Fail closed: never overwrite or continue from unreadable/corrupt state; no model calls.
|
|
473
|
+
const code = error instanceof CollaborationError && error.code === "persistence-corrupt" ? "persistence-corrupt" : "persistence-failure";
|
|
474
|
+
try { this.onEvent({ type: "persistence-failure", runId, code, message: error?.message ?? String(error) }); } catch { /* ignore */ }
|
|
475
|
+
return { runId, status: "persistence-failure", stopReason: `${code}: ${error?.message ?? error}`, persisted: false, rounds: 0, tokensUsed: 0, questions: [], tasks: [], artifacts: [] };
|
|
476
|
+
}
|
|
477
|
+
if (state && TERMINAL_STATUSES.includes(state.status)) return this.#result(state); // idempotent replay
|
|
478
|
+
if (!state) state = this.#initialState(runId, input);
|
|
479
|
+
else if (input.needs) state.needs.answers = { ...state.needs.answers, ...sanitizeAnswers(input.needs) };
|
|
480
|
+
FATAL.delete(state);
|
|
481
|
+
const ctx = { state, startedAt: this.clock.now() };
|
|
482
|
+
let rethrow = null;
|
|
483
|
+
try {
|
|
484
|
+
this.#check(ctx);
|
|
485
|
+
state.status = "running";
|
|
486
|
+
if (!state.needs.resolved) { await this.#needsPhase(ctx); if (!state.needs.resolved) { state.status = "awaiting-needs"; this.#event(state, "awaiting-needs", { missing: state.needs.missing }); await this.#save(state); return this.#result(state); } }
|
|
487
|
+
if (!state.planned) await this.#planPhase(ctx);
|
|
488
|
+
await this.#cycles(ctx);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const stop = FATAL.get(state) ?? error;
|
|
491
|
+
if (stop instanceof StopSignal) { state.status = stop.reason; state.stopReason = stop.message; }
|
|
492
|
+
else if (stop instanceof CollaborationError && stop.code === "invalid-plan") { state.status = "failed"; state.stopReason = stop.message; }
|
|
493
|
+
else { state.status = "interrupted"; state.stopReason = stop?.message || String(stop); rethrow = stop; }
|
|
494
|
+
this.#event(state, "stopped", { status: state.status, reason: state.stopReason });
|
|
495
|
+
}
|
|
496
|
+
if (state.status === "persistence-failure") return this.#result(state, false);
|
|
497
|
+
if (state.status !== "memory-failure" && !rethrow) {
|
|
498
|
+
try { await this.#remember(state, "outcome", { status: state.status, reason: state.stopReason, approved: Object.values(state.artifacts).filter(a => a.status === "approved").map(a => a.id) }); }
|
|
499
|
+
catch (error) { if (!(error instanceof StopSignal)) throw error; state.status = error.reason; state.stopReason = error.message; this.#event(state, "stopped", { status: state.status, reason: state.stopReason }); }
|
|
500
|
+
}
|
|
501
|
+
try { await this.#save(state); }
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (!(error instanceof StopSignal)) throw error;
|
|
504
|
+
if (rethrow) throw rethrow;
|
|
505
|
+
state.status = error.reason; state.stopReason = error.message;
|
|
506
|
+
return this.#result(state, false);
|
|
507
|
+
}
|
|
508
|
+
if (rethrow) throw rethrow;
|
|
509
|
+
return this.#result(state);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#fatal(state, reason, message) {
|
|
513
|
+
if (!FATAL.has(state)) { FATAL.set(state, new StopSignal(reason, message)); this.#event(state, reason, { message }); }
|
|
514
|
+
return FATAL.get(state);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#initialState(runId, input) {
|
|
518
|
+
const goal = String(input.goal || "").trim();
|
|
519
|
+
if (!goal) throw new CollaborationError("invalid-run", "A goal is required for a new run");
|
|
520
|
+
return {
|
|
521
|
+
version: COLLABORATION_VERSION, runId, goal, status: "pending", stopReason: null,
|
|
522
|
+
roster: this.roster.map(s => ({ id: s.id, role: s.role })),
|
|
523
|
+
needs: { questions: [], answers: sanitizeAnswers(input.needs), missing: [], resolved: false, asked: false },
|
|
524
|
+
planned: false, verifiedByHost: false, round: 0, activeRound: null, tokensUsed: 0, calls: {}, tasks: {}, artifacts: {}, events: [], seq: 0,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#check(ctx) {
|
|
529
|
+
if (FATAL.has(ctx.state)) throw FATAL.get(ctx.state);
|
|
530
|
+
if (this.signal.aborted) throw new StopSignal("cancelled", String(this.signal.reason ?? "cancelled"));
|
|
531
|
+
if (this.clock.now() - ctx.startedAt > this.limits.maxDurationMs) throw new StopSignal("time-limit", `Exceeded ${this.limits.maxDurationMs}ms`);
|
|
532
|
+
if (ctx.state.tokensUsed >= this.limits.maxTokens) throw new StopSignal("token-limit", `Used ${ctx.state.tokensUsed} of ${this.limits.maxTokens} tokens`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#event(state, type, data = {}) {
|
|
536
|
+
const event = { seq: ++state.seq, type, round: state.activeRound ?? state.round, ...data };
|
|
537
|
+
state.events.push(event);
|
|
538
|
+
if (state.events.length > this.limits.maxEvents) state.events.splice(0, state.events.length - this.limits.maxEvents);
|
|
539
|
+
try { this.onEvent(clone(event)); } catch { /* observer errors are ignored */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#save(state) {
|
|
543
|
+
const snapshot = clone(state);
|
|
544
|
+
const pending = this.saveChain.then(() => this.persistence.save(state.runId, snapshot));
|
|
545
|
+
this.saveChain = pending.catch(() => {});
|
|
546
|
+
return pending.catch(error => { throw this.#fatal(state, "persistence-failure", `Persistence save failed: ${error?.message ?? error}`); });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async #recall(state, phase, slot, query) {
|
|
550
|
+
if (FATAL.has(state)) throw FATAL.get(state);
|
|
551
|
+
try {
|
|
552
|
+
const r = await this.memory.recall({ runId: state.runId, phase, slot: slot.id, role: slot.role, query });
|
|
553
|
+
if (!Array.isArray(r)) throw new CollaborationError("memory-corrupt", "recall() must return an array");
|
|
554
|
+
return clone(r.slice(0, 20));
|
|
555
|
+
} catch (error) {
|
|
556
|
+
if (this.memoryStrict) throw this.#fatal(state, "memory-failure", `Memory recall failed (${phase}): ${error?.message ?? error}`);
|
|
557
|
+
this.#event(state, "memory-error", { op: "recall", message: error?.message ?? String(error) }); return [];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async #remember(state, kind, data, extra = {}) {
|
|
561
|
+
if (FATAL.has(state)) throw FATAL.get(state);
|
|
562
|
+
try { await this.memory.record({ runId: state.runId, kind, round: state.activeRound ?? state.round, ...extra, data: clone(data) }); }
|
|
563
|
+
catch (error) {
|
|
564
|
+
if (this.memoryStrict) throw this.#fatal(state, "memory-failure", `Memory record failed (${kind}): ${error?.message ?? error}`);
|
|
565
|
+
this.#event(state, "memory-error", { op: "record", kind, message: error?.message ?? String(error) });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Idempotent, budgeted, concurrency-limited model invocation. */
|
|
570
|
+
async #call(ctx, slot, phase, input, keyParts) {
|
|
571
|
+
const { state } = ctx;
|
|
572
|
+
const key = hashOf({ runId: state.runId, phase, slot: slot.id, ...keyParts });
|
|
573
|
+
if (state.calls[key]) return state.calls[key].output;
|
|
574
|
+
if (this.inflight.has(key)) return this.inflight.get(key);
|
|
575
|
+
const promise = this.semaphore.run(async () => {
|
|
576
|
+
this.#check(ctx);
|
|
577
|
+
// Reserve a local estimate of the prompt before calling; never trust the model for spend accounting.
|
|
578
|
+
const reserve = estimateTokens(input);
|
|
579
|
+
const remaining = this.limits.maxTokens - state.tokensUsed - reserve;
|
|
580
|
+
if (remaining < 1) throw new StopSignal("token-limit", `Reserving ${reserve} prompt tokens would exceed ${this.limits.maxTokens} (used ${state.tokensUsed})`);
|
|
581
|
+
const maxTokens = Math.min(this.limits.maxTokensPerCall, remaining);
|
|
582
|
+
const request = { idempotencyKey: key, runId: state.runId, phase, slot: { id: slot.id, role: slot.role, model: slot.model, instructions: slot.instructions }, input: clone(input), maxTokens, signal: this.signal };
|
|
583
|
+
this.#event(state, "call", { phase, slot: slot.id, key: key.slice(0, 16), taskId: keyParts.taskId });
|
|
584
|
+
const result = await abortable(Promise.resolve().then(() => this.model.complete(request)), this.signal);
|
|
585
|
+
const { tokens, invalid } = chargeUsage(result, reserve);
|
|
586
|
+
if (invalid) this.#event(state, "usage-invalid", { phase, slot: slot.id, fields: invalid });
|
|
587
|
+
state.tokensUsed += tokens;
|
|
588
|
+
let output, parseError = null;
|
|
589
|
+
try { output = parseModelOutput(result); } catch (error) { parseError = error.message; output = { __invalid: parseError }; }
|
|
590
|
+
state.calls[key] = { phase, slot: slot.id, tokens, output };
|
|
591
|
+
await this.#save(state);
|
|
592
|
+
if (state.tokensUsed > this.limits.maxTokens) throw new StopSignal("token-limit", `Used ${state.tokensUsed} of ${this.limits.maxTokens} tokens`);
|
|
593
|
+
return output;
|
|
594
|
+
});
|
|
595
|
+
this.inflight.set(key, promise);
|
|
596
|
+
try { return await promise; } finally { this.inflight.delete(key); }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async #needsPhase(ctx) {
|
|
600
|
+
const { state } = ctx;
|
|
601
|
+
const slot = this.slots("needs")[0];
|
|
602
|
+
if (!state.needs.questions.length) {
|
|
603
|
+
const memory = await this.#recall(state, "needs", slot, state.goal);
|
|
604
|
+
const out = await this.#call(ctx, slot, "needs", { goal: state.goal, baseline: BASELINE_NEEDS, known: state.needs.answers, memory }, { step: "questions" });
|
|
605
|
+
const extra = Array.isArray(out?.questions) ? out.questions : [];
|
|
606
|
+
const merged = new Map(BASELINE_NEEDS.map(q => [q.id, { ...q }]));
|
|
607
|
+
for (const q of extra) {
|
|
608
|
+
if (!q || typeof q.question !== "string") continue;
|
|
609
|
+
const id = String(q.id || q.question).replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 64);
|
|
610
|
+
if (!merged.has(id)) merged.set(id, { id, question: q.question.slice(0, 500), required: q.required === true });
|
|
611
|
+
}
|
|
612
|
+
state.needs.questions = [...merged.values()];
|
|
613
|
+
this.#event(state, "needs-questions", { count: state.needs.questions.length });
|
|
614
|
+
}
|
|
615
|
+
const missing = () => {
|
|
616
|
+
const out = state.needs.questions.filter(q => q.required && !hasAnswer(state.needs.answers[q.id])).map(q => q.id);
|
|
617
|
+
state.needs.invalid = {};
|
|
618
|
+
if (!out.includes("allowedPaths")) {
|
|
619
|
+
try { normalizeAllowedPaths(state.needs.answers.allowedPaths); }
|
|
620
|
+
catch (error) { out.push("allowedPaths"); state.needs.invalid.allowedPaths = error.message; }
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
};
|
|
624
|
+
if (missing().length && this.needsProvider && !state.needs.asked) {
|
|
625
|
+
this.#check(ctx);
|
|
626
|
+
const answers = await abortable(Promise.resolve(this.needsProvider.ask({ runId: state.runId, goal: state.goal, questions: clone(state.needs.questions), missing: missing(), signal: this.signal })), this.signal);
|
|
627
|
+
state.needs.answers = { ...state.needs.answers, ...sanitizeAnswers(answers) };
|
|
628
|
+
state.needs.asked = true;
|
|
629
|
+
}
|
|
630
|
+
state.needs.missing = missing();
|
|
631
|
+
state.needs.resolved = state.needs.missing.length === 0;
|
|
632
|
+
if (state.needs.resolved) { this.#event(state, "needs-resolved", {}); await this.#remember(state, "needs", state.needs.answers); }
|
|
633
|
+
await this.#save(state);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
#allowedPaths(state) {
|
|
637
|
+
try { return normalizeAllowedPaths(state.needs.answers.allowedPaths); } catch { return []; } // empty => every patch refused
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async #planPhase(ctx) {
|
|
641
|
+
const { state } = ctx;
|
|
642
|
+
const slot = this.slots("planner")[0];
|
|
643
|
+
const memory = await this.#recall(state, "plan", slot, state.goal);
|
|
644
|
+
const out = await this.#call(ctx, slot, "plan", { goal: state.goal, needs: state.needs.answers, maxTasks: this.limits.maxTasks, memory }, { step: "plan" });
|
|
645
|
+
if (out?.__invalid) throw new CollaborationError("invalid-plan", `Planner output invalid: ${out.__invalid}`);
|
|
646
|
+
state.tasks = normalizePlan(out?.tasks, { maxTasks: this.limits.maxTasks });
|
|
647
|
+
state.planned = true;
|
|
648
|
+
this.#event(state, "planned", { tasks: Object.keys(state.tasks) });
|
|
649
|
+
await this.#remember(state, "plan", Object.values(state.tasks).map(({ id, title, dependsOn }) => ({ id, title, dependsOn })));
|
|
650
|
+
await this.#save(state);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async #cycles(ctx) {
|
|
654
|
+
const { state } = ctx;
|
|
655
|
+
const builders = this.slots("builder");
|
|
656
|
+
for (;;) {
|
|
657
|
+
propagateBlocked(state.tasks);
|
|
658
|
+
const tasks = Object.values(state.tasks);
|
|
659
|
+
if (tasks.every(t => t.status === "done")) {
|
|
660
|
+
// Model tests and reviews are advisory. Only an injected deterministic host verifier makes a run "completed".
|
|
661
|
+
state.verifiedByHost = this.verifier !== null;
|
|
662
|
+
state.status = state.verifiedByHost || this.allowAdvisory ? "completed" : "needs-host-verification";
|
|
663
|
+
state.stopReason = state.verifiedByHost ? "all tasks approved and host-verified" : "all tasks peer-approved; model checks are advisory and not host-verified";
|
|
664
|
+
this.#event(state, state.status, { verifiedByHost: state.verifiedByHost });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (state.activeRound !== null && !tasks.some(t => t.status === "in-progress")) state.activeRound = null; // crash after round finished
|
|
668
|
+
const ready = state.activeRound !== null ? tasks.filter(t => t.status === "in-progress") : readyTasks(state.tasks);
|
|
669
|
+
if (!ready.length) {
|
|
670
|
+
const failed = tasks.filter(t => t.status === "failed").map(t => t.id);
|
|
671
|
+
state.status = "failed"; state.stopReason = `No runnable tasks; failed: ${failed.join(", ") || "none"}; blocked: ${tasks.filter(t => t.status === "blocked").map(t => t.id).join(", ") || "none"}`;
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (state.activeRound === null) {
|
|
675
|
+
if (state.round >= this.limits.maxRounds) throw new StopSignal("round-limit", `Reached ${this.limits.maxRounds} rounds`);
|
|
676
|
+
this.#check(ctx);
|
|
677
|
+
state.round += 1; state.activeRound = state.round;
|
|
678
|
+
for (const t of ready) { t.status = "in-progress"; t.assignee ??= builders[Object.keys(state.tasks).indexOf(t.id) % builders.length].id; }
|
|
679
|
+
this.#event(state, "round-start", { tasks: ready.map(t => t.id) });
|
|
680
|
+
await this.#save(state);
|
|
681
|
+
}
|
|
682
|
+
const results = await Promise.allSettled(ready.map(task => this.#taskCycle(ctx, task)));
|
|
683
|
+
const stop = results.find(r => r.status === "rejected");
|
|
684
|
+
if (stop) throw FATAL.get(state) ?? stop.reason;
|
|
685
|
+
state.activeRound = null;
|
|
686
|
+
this.#event(state, "round-end", { done: Object.values(state.tasks).filter(t => t.status === "done").length });
|
|
687
|
+
await this.#save(state);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async #taskCycle(ctx, task) {
|
|
692
|
+
const { state } = ctx;
|
|
693
|
+
const round = state.activeRound;
|
|
694
|
+
const author = this.roster.find(s => s.id === task.assignee) || this.slots("builder")[0];
|
|
695
|
+
const attempt = task.attempts;
|
|
696
|
+
const reject = async (reason, findings, artifact) => {
|
|
697
|
+
task.feedback.push({ round, attempt, reason, findings: findings.slice(0, 20) });
|
|
698
|
+
task.attempts += 1;
|
|
699
|
+
task.status = task.attempts >= this.limits.maxAttemptsPerTask ? "failed" : "pending";
|
|
700
|
+
if (artifact) artifact.status = "rejected";
|
|
701
|
+
this.#event(state, "task-rejected", { taskId: task.id, reason, final: task.status === "failed" });
|
|
702
|
+
await this.#remember(state, "review", { reason, findings }, { taskId: task.id });
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// build
|
|
706
|
+
const deps = task.dependsOn.map(d => ({ id: d, title: state.tasks[d].title, approved: state.tasks[d].proposals.map(id => state.artifacts[id]).filter(a => a?.status === "approved").map(a => ({ id: a.id, summary: a.summary, paths: a.patches.map(p => p.path) })) }));
|
|
707
|
+
const memory = await this.#recall(state, "build", author, task.title);
|
|
708
|
+
const raw = await this.#call(ctx, author, "build", { goal: state.goal, needs: state.needs.answers, task: pickTask(task), dependencies: deps, feedback: task.feedback, memory, contract: "Return {summary, patches:[{path, op:create|modify|delete, diff|content}]}. Never return commands." }, { taskId: task.id, attempt, step: "build" });
|
|
709
|
+
let proposal;
|
|
710
|
+
try {
|
|
711
|
+
if (raw?.__invalid) throw new CollaborationError("invalid-output", raw.__invalid);
|
|
712
|
+
proposal = validateProposal(raw, { allowedPaths: this.#allowedPaths(state), protectedPaths: this.protectedPaths, maintenancePaths: this.maintenancePaths, maxPatchBytes: this.limits.maxPatchBytes, maxFilesPerProposal: this.limits.maxFilesPerProposal });
|
|
713
|
+
} catch (error) { return reject(error.code || "invalid-proposal", [error.message]); }
|
|
714
|
+
const contentHash = hashOf({ taskId: task.id, patches: proposal.patches });
|
|
715
|
+
const id = `patch-${contentHash.slice(0, 16)}`;
|
|
716
|
+
const existing = state.artifacts[id];
|
|
717
|
+
if (existing && existing.status === "rejected") return reject("duplicate-proposal", [`Proposal ${id} is identical to a previously rejected proposal`]);
|
|
718
|
+
if (existing && existing.status === "approved") { task.status = "done"; return; }
|
|
719
|
+
const artifact = existing || { id, kind: "patch-proposal", taskId: task.id, author: author.id, round, attempt, hash: contentHash, summary: proposal.summary, patches: proposal.patches, bytes: proposal.bytes, status: "proposed", tests: [], reviews: [] };
|
|
720
|
+
if (!existing) { state.artifacts[id] = artifact; task.proposals.push(id); this.#event(state, "proposal", { taskId: task.id, artifactId: id }); await this.#remember(state, "proposal", { id, summary: artifact.summary, paths: artifact.patches.map(p => p.path) }, { taskId: task.id }); }
|
|
721
|
+
await this.#save(state);
|
|
722
|
+
|
|
723
|
+
// test
|
|
724
|
+
const testers = this.slots("tester");
|
|
725
|
+
const findings = [];
|
|
726
|
+
let passed = true;
|
|
727
|
+
const testOutputs = await Promise.all(testers.map(slot => this.#call(ctx, slot, "test", { task: pickTask(task), needs: state.needs.answers, proposal: publicArtifact(artifact) }, { taskId: task.id, artifactId: id, step: "test" })));
|
|
728
|
+
testOutputs.forEach((out, i) => {
|
|
729
|
+
const ok = out?.passed === true;
|
|
730
|
+
artifact.tests.push({ slot: testers[i].id, passed: ok, findings: listOf(out?.findings ?? out?.__invalid) });
|
|
731
|
+
if (!ok) { passed = false; findings.push(...listOf(out?.findings ?? out?.__invalid ?? "tester did not pass the proposal")); }
|
|
732
|
+
});
|
|
733
|
+
if (this.verifier) {
|
|
734
|
+
this.#check(ctx);
|
|
735
|
+
const v = await abortable(Promise.resolve(this.verifier.verify(publicArtifact(artifact), { task: pickTask(task), signal: this.signal })), this.signal);
|
|
736
|
+
artifact.tests.push({ slot: "verifier", passed: v?.passed === true, findings: listOf(v?.findings) });
|
|
737
|
+
if (v?.passed !== true) { passed = false; findings.push(...listOf(v?.findings ?? "verifier rejected the proposal")); }
|
|
738
|
+
}
|
|
739
|
+
await this.#remember(state, "test", { artifactId: id, passed, findings }, { taskId: task.id });
|
|
740
|
+
if (!passed) return reject("tests-failed", findings, artifact);
|
|
741
|
+
|
|
742
|
+
// peer review: every reviewer slot plus one builder that is not the author
|
|
743
|
+
const peer = this.slots("builder").find(s => s.id !== author.id);
|
|
744
|
+
const reviewers = [...this.slots("reviewer"), ...(peer ? [peer] : [])].filter(s => s.id !== author.id);
|
|
745
|
+
const reviews = await Promise.all(reviewers.map(slot => this.#call(ctx, slot, "review", { task: pickTask(task), needs: state.needs.answers, proposal: publicArtifact(artifact), tests: artifact.tests }, { taskId: task.id, artifactId: id, step: "review" })));
|
|
746
|
+
const comments = [];
|
|
747
|
+
let approved = reviews.length > 0;
|
|
748
|
+
reviews.forEach((out, i) => {
|
|
749
|
+
const verdict = out?.verdict === "approve" ? "approve" : "request-changes";
|
|
750
|
+
artifact.reviews.push({ slot: reviewers[i].id, verdict, comments: listOf(out?.comments ?? out?.__invalid) });
|
|
751
|
+
if (verdict !== "approve") { approved = false; comments.push(...listOf(out?.comments ?? out?.__invalid ?? "changes requested")); }
|
|
752
|
+
});
|
|
753
|
+
if (!approved) return reject("changes-requested", comments, artifact);
|
|
754
|
+
artifact.status = "approved";
|
|
755
|
+
task.status = "done";
|
|
756
|
+
this.#event(state, "task-done", { taskId: task.id, artifactId: id });
|
|
757
|
+
await this.#remember(state, "approval", { artifactId: id, reviewers: reviewers.map(r => r.id) }, { taskId: task.id });
|
|
758
|
+
if (this.artifactSink) await this.artifactSink.put(clone(artifact));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#result(state, persisted = true) {
|
|
762
|
+
return clone({
|
|
763
|
+
runId: state.runId, status: state.status, stopReason: state.stopReason, verifiedByHost: state.verifiedByHost === true, persisted, rounds: state.round, tokensUsed: state.tokensUsed,
|
|
764
|
+
questions: state.status === "awaiting-needs" ? state.needs.questions.filter(q => state.needs.missing.includes(q.id)).map(q => (state.needs.invalid?.[q.id] ? { ...q, invalid: state.needs.invalid[q.id] } : q)) : [],
|
|
765
|
+
tasks: Object.values(state.tasks).map(t => ({ id: t.id, title: t.title, status: t.status, dependsOn: t.dependsOn, attempts: t.attempts, assignee: t.assignee })),
|
|
766
|
+
artifacts: Object.values(state.artifacts).filter(a => a.status === "approved"),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const nonNegativeInt = v => Number.isSafeInteger(v) && v >= 0;
|
|
772
|
+
/** Spend accounting: reported usage only counts when every field is a non-negative safe integer, and never below the local estimate. */
|
|
773
|
+
export function chargeUsage(result, promptEstimate = 0) {
|
|
774
|
+
const usage = result && typeof result === "object" && result.usage && typeof result.usage === "object" ? result.usage : {};
|
|
775
|
+
const invalid = ["inputTokens", "outputTokens", "totalTokens"].filter(k => usage[k] !== undefined && !nonNegativeInt(usage[k]));
|
|
776
|
+
const content = result && typeof result === "object" ? (result.content ?? result.text ?? result.output ?? "") : (result ?? "");
|
|
777
|
+
const estimate = promptEstimate + estimateTokens(content);
|
|
778
|
+
let reported = 0;
|
|
779
|
+
if (!invalid.length) reported = usage.totalTokens !== undefined ? usage.totalTokens : (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
780
|
+
return { tokens: Math.max(estimate, Number.isSafeInteger(reported) ? reported : 0), invalid: invalid.length ? invalid : null };
|
|
781
|
+
}
|
|
782
|
+
const plainObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
783
|
+
/** Structural check of persisted run state; throws persistence-corrupt so callers fail closed. */
|
|
784
|
+
export function validateRunState(state, runId) {
|
|
785
|
+
const bad = why => { throw new CollaborationError("persistence-corrupt", `Persisted run state is corrupt: ${why}`); };
|
|
786
|
+
if (!plainObject(state)) bad("not an object");
|
|
787
|
+
if (state.version !== COLLABORATION_VERSION) bad(`unsupported version ${state.version}`);
|
|
788
|
+
if (runId !== undefined && state.runId !== runId) bad("runId mismatch");
|
|
789
|
+
if (typeof state.goal !== "string" || typeof state.status !== "string") bad("goal/status");
|
|
790
|
+
for (const k of ["round", "tokensUsed", "seq"]) if (!Number.isSafeInteger(state[k]) || state[k] < 0) bad(k);
|
|
791
|
+
if (state.activeRound !== null && !Number.isSafeInteger(state.activeRound)) bad("activeRound");
|
|
792
|
+
if (!plainObject(state.needs) || !plainObject(state.needs.answers) || !Array.isArray(state.needs.questions) || !Array.isArray(state.needs.missing)) bad("needs");
|
|
793
|
+
for (const k of ["tasks", "artifacts", "calls"]) if (!plainObject(state[k])) bad(k);
|
|
794
|
+
if (!Array.isArray(state.events)) bad("events");
|
|
795
|
+
for (const [id, t] of Object.entries(state.tasks)) {
|
|
796
|
+
if (!plainObject(t) || t.id !== id || !TASK_STATUSES.has(t.status) || !Array.isArray(t.dependsOn) || !Array.isArray(t.feedback) || !Array.isArray(t.proposals) || !Number.isSafeInteger(t.attempts)) bad(`task ${id}`);
|
|
797
|
+
if (t.dependsOn.some(d => !Object.hasOwn(state.tasks, d))) bad(`task ${id} dependency`);
|
|
798
|
+
if (t.proposals.some(p => !Object.hasOwn(state.artifacts, p))) bad(`task ${id} proposal`);
|
|
799
|
+
}
|
|
800
|
+
for (const [id, a] of Object.entries(state.artifacts)) if (!plainObject(a) || a.id !== id || !Array.isArray(a.patches) || !Object.hasOwn(state.tasks, a.taskId)) bad(`artifact ${id}`);
|
|
801
|
+
for (const [key, c] of Object.entries(state.calls)) if (!plainObject(c) || !/^[0-9a-f]{64}$/.test(key) || !Number.isSafeInteger(c.tokens)) bad("call cache");
|
|
802
|
+
return state;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function hasAnswer(v) { return v !== undefined && v !== null && !(typeof v === "string" && !v.trim()) && !(Array.isArray(v) && !v.length); }
|
|
806
|
+
function sanitizeAnswers(answers) {
|
|
807
|
+
if (!answers || typeof answers !== "object") return {};
|
|
808
|
+
const out = {};
|
|
809
|
+
for (const [k, v] of Object.entries(answers)) if (/^[A-Za-z0-9_-]{1,64}$/.test(k) && hasAnswer(v)) out[k] = clone(v);
|
|
810
|
+
return out;
|
|
811
|
+
}
|
|
812
|
+
const listOf = v => (v === undefined || v === null ? [] : (Array.isArray(v) ? v : [v]).map(x => String(typeof x === "object" ? stableStringify(x) : x).slice(0, 1000)));
|
|
813
|
+
const pickTask = t => ({ id: t.id, title: t.title, description: t.description, dependsOn: t.dependsOn });
|
|
814
|
+
const publicArtifact = a => ({ id: a.id, taskId: a.taskId, author: a.author, summary: a.summary, patches: a.patches });
|
|
815
|
+
|
|
816
|
+
export function createCollaboration(options) { return new CollaborationScheduler(options); }
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Bind seven model identifiers (opaque strings, e.g. from a host cohort list) to roster slots in order.
|
|
820
|
+
* `models` may be an array of 7 ids/objects ({model, id?, role?, instructions?}) or a slotId->model map.
|
|
821
|
+
*/
|
|
822
|
+
export function rosterFromModels(models, base = DEFAULT_ROSTER) {
|
|
823
|
+
if (Array.isArray(models)) {
|
|
824
|
+
if (models.length !== ROSTER_SIZE) throw new CollaborationError("invalid-roster", `Expected ${ROSTER_SIZE} models`);
|
|
825
|
+
return createRoster(base.map((slot, i) => (typeof models[i] === "string" ? { ...slot, model: models[i] } : { ...slot, ...models[i] })));
|
|
826
|
+
}
|
|
827
|
+
if (models && typeof models === "object") return createRoster(Object.fromEntries(Object.entries(models).map(([id, m]) => [id, typeof m === "string" ? { model: m } : m])));
|
|
828
|
+
throw new CollaborationError("invalid-roster", "models must be an array or slot-keyed object");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Continuity bundle for bootstrap: wraps host persistence + memory so a CLI can resume runs across processes.
|
|
833
|
+
* Each argument is optional; unspecified parts fall back to in-memory/no-op implementations.
|
|
834
|
+
*/
|
|
835
|
+
export function createContinuity({ persistence, memory, continuity, prefix } = {}) {
|
|
836
|
+
if (memory && continuity) throw new CollaborationError("invalid-config", "Pass memory or continuity, not both");
|
|
837
|
+
return { persistence: persistence ?? createMemoryPersistence(), memory: memory ?? (continuity ? memoryFromContinuity(continuity, { prefix }) : NOOP_MEMORY) };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Adapt any continuity journal exposing `remember(kind, content)` and `recall({kind?, limit?})`
|
|
842
|
+
* (duck-typed; no import of a concrete module) into collaboration memory hooks.
|
|
843
|
+
* Fails closed when the journal reports `verify() === false`, or is non-durable
|
|
844
|
+
* (`status().durable === false`) while `requireDurable` is true (the default).
|
|
845
|
+
* Content is canonicalized (non-integer numbers become strings, undefined dropped) and
|
|
846
|
+
* entries larger than `maxEntryBytes` are replaced with a digest stub.
|
|
847
|
+
*/
|
|
848
|
+
export function memoryFromContinuity(continuity, { prefix = "collab", limit = 20, requireDurable = true, maxEntryBytes = 12 * 1024 } = {}) {
|
|
849
|
+
if (typeof continuity?.remember !== "function" || typeof continuity?.recall !== "function") throw new CollaborationError("invalid-config", "continuity must implement remember(kind, content) and recall(query)");
|
|
850
|
+
if (!/^[a-z][a-z0-9_-]{0,23}$/.test(prefix)) throw new CollaborationError("invalid-config", "prefix must match [a-z][a-z0-9_-]{0,23}");
|
|
851
|
+
const guard = () => {
|
|
852
|
+
if (typeof continuity.verify === "function" && continuity.verify() !== true) throw new CollaborationError("memory-corrupt", "continuity journal failed verification");
|
|
853
|
+
if (requireDurable && typeof continuity.status === "function" && continuity.status()?.durable === false) throw new CollaborationError("memory-not-durable", "continuity journal is not durable");
|
|
854
|
+
};
|
|
855
|
+
const canonical = value => JSON.parse(JSON.stringify(value, (k, x) => (typeof x === "number" && !Number.isSafeInteger(x) ? String(x) : x)) ?? "null");
|
|
856
|
+
return {
|
|
857
|
+
async recall({ runId, phase }) {
|
|
858
|
+
guard();
|
|
859
|
+
const entries = await continuity.recall({ limit: limit * 5 });
|
|
860
|
+
if (!Array.isArray(entries)) throw new CollaborationError("memory-corrupt", "continuity recall() must return an array");
|
|
861
|
+
return entries
|
|
862
|
+
.filter(e => typeof e?.kind === "string" && e.kind.startsWith(`${prefix}:`))
|
|
863
|
+
.map(e => ({ kind: e.kind, runId: e.content?.runId, round: e.content?.round, taskId: e.content?.taskId, data: e.content?.data }))
|
|
864
|
+
.filter(e => phase === "needs" || e.runId === runId || e.kind === `${prefix}:outcome`)
|
|
865
|
+
.slice(-limit);
|
|
866
|
+
},
|
|
867
|
+
async record({ kind, ...rest }) {
|
|
868
|
+
guard();
|
|
869
|
+
let content = canonical(rest);
|
|
870
|
+
const bytes = Buffer.byteLength(JSON.stringify(content));
|
|
871
|
+
if (bytes > maxEntryBytes) content = canonical({ runId: rest.runId, round: rest.round, taskId: rest.taskId, data: { truncated: true, bytes, sha256: hashOf(content) } });
|
|
872
|
+
await continuity.remember(`${prefix}:${String(kind).toLowerCase().replace(/[^a-z0-9_-]/g, "-")}`, content);
|
|
873
|
+
},
|
|
874
|
+
};
|
|
875
|
+
}
|