@chrok/pi-braid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/dist/integrations/pi/command.js +190 -0
- package/dist/integrations/pi/display.js +417 -0
- package/dist/integrations/pi/index.js +232 -0
- package/dist/integrations/pi/jobs.js +194 -0
- package/dist/integrations/pi/read-tools.js +54 -0
- package/dist/integrations/pi/runner.js +342 -0
- package/dist/integrations/pi/workspaces.js +2 -0
- package/dist/integrations/pi/write-tools.js +74 -0
- package/dist/src/adapters/openai.js +240 -0
- package/dist/src/budgets.js +19 -0
- package/dist/src/index.js +2 -0
- package/dist/src/merge-tools.js +96 -0
- package/dist/src/runtime.js +532 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validate.js +110 -0
- package/dist/src/workspaces.js +494 -0
- package/package.json +64 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { compileGraph } from "./validate.js";
|
|
2
|
+
import { GitWorkspaces } from "./workspaces.js";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
class RunError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "BraidExecutionError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function describeError(error) {
|
|
13
|
+
return {
|
|
14
|
+
code: error instanceof RunError ? error.code : "MODEL_ERROR",
|
|
15
|
+
message: error instanceof Error
|
|
16
|
+
? error.message
|
|
17
|
+
: typeof error === "string"
|
|
18
|
+
? error
|
|
19
|
+
: "Model runner threw a non-Error value",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function validateOptions(options) {
|
|
23
|
+
if (!options || typeof options.runner !== "function")
|
|
24
|
+
throw new TypeError("A model runner is required");
|
|
25
|
+
if (options.cwd !== undefined && (typeof options.cwd !== "string" || !options.cwd.trim()))
|
|
26
|
+
throw new TypeError("cwd must be a non-empty directory path");
|
|
27
|
+
if (options.defaultModel !== undefined &&
|
|
28
|
+
(typeof options.defaultModel !== "string" || !options.defaultModel.trim())) {
|
|
29
|
+
throw new TypeError("defaultModel must be a non-empty string");
|
|
30
|
+
}
|
|
31
|
+
if (options.signal !== undefined &&
|
|
32
|
+
!(options.signal instanceof AbortSignal)) {
|
|
33
|
+
throw new TypeError("signal must be an AbortSignal");
|
|
34
|
+
}
|
|
35
|
+
if (options.onEvent !== undefined && typeof options.onEvent !== "function") {
|
|
36
|
+
throw new TypeError("onEvent must be a function");
|
|
37
|
+
}
|
|
38
|
+
const concurrency = options.maxConcurrency === undefined ? 4 : options.maxConcurrency;
|
|
39
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
|
|
40
|
+
throw new TypeError("maxConcurrency must be a positive integer");
|
|
41
|
+
}
|
|
42
|
+
for (const key of ["nodeTimeoutMs", "graphTimeoutMs"]) {
|
|
43
|
+
const value = options[key];
|
|
44
|
+
if (value !== undefined &&
|
|
45
|
+
value !== Infinity &&
|
|
46
|
+
(!Number.isFinite(value) || value <= 0 || value > 2_147_483_647)) {
|
|
47
|
+
throw new TypeError(`${key} must be Infinity or a positive number no greater than 2147483647`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function createEventLog(onEvent) {
|
|
52
|
+
const events = [];
|
|
53
|
+
let sequence = 0;
|
|
54
|
+
return {
|
|
55
|
+
events,
|
|
56
|
+
emit(payload) {
|
|
57
|
+
const event = Object.freeze({
|
|
58
|
+
...structuredClone(payload),
|
|
59
|
+
sequence: ++sequence,
|
|
60
|
+
timestamp: Date.now(),
|
|
61
|
+
});
|
|
62
|
+
if ("error" in event)
|
|
63
|
+
Object.freeze(event.error);
|
|
64
|
+
if ("usage" in event && event.usage)
|
|
65
|
+
Object.freeze(event.usage);
|
|
66
|
+
if ("terminalNodeIds" in event)
|
|
67
|
+
Object.freeze(event.terminalNodeIds);
|
|
68
|
+
if ("workspace" in event)
|
|
69
|
+
Object.freeze(event.workspace);
|
|
70
|
+
events.push(event);
|
|
71
|
+
if (onEvent) {
|
|
72
|
+
try {
|
|
73
|
+
// Observe rejections too if a caller supplies an async callback; never await it.
|
|
74
|
+
void Promise.resolve(onEvent(event)).catch(() => { });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Observers are diagnostic only and must never fail node execution.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function edgeState(edge, results) {
|
|
84
|
+
const source = results.get(edge.from);
|
|
85
|
+
switch (source.status) {
|
|
86
|
+
case "completed":
|
|
87
|
+
return edge.choice === undefined || edge.choice === source.decision
|
|
88
|
+
? "active"
|
|
89
|
+
: "inactive";
|
|
90
|
+
case "failed":
|
|
91
|
+
return edge.choice === undefined ? "active" : "blocked";
|
|
92
|
+
case "skipped":
|
|
93
|
+
// Failure is not inactivity: a downstream join must not silently lose required input.
|
|
94
|
+
return source.skipReason === "inactive" ? "inactive" : "blocked";
|
|
95
|
+
default:
|
|
96
|
+
return "unresolved";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function skip(result, reason, log) {
|
|
100
|
+
result.status = "skipped";
|
|
101
|
+
result.skipReason = reason;
|
|
102
|
+
result.finishedAt = Date.now();
|
|
103
|
+
log.emit({ type: "node_skipped", nodeId: result.id, reason });
|
|
104
|
+
}
|
|
105
|
+
function resolveDependencies(graph, results, log) {
|
|
106
|
+
// Topological order propagates arbitrarily long skipped paths in one pass.
|
|
107
|
+
for (const node of graph.topologicalOrder) {
|
|
108
|
+
const result = results.get(node.id);
|
|
109
|
+
if (result.status !== "pending")
|
|
110
|
+
continue;
|
|
111
|
+
const incoming = graph.incoming.get(node.id);
|
|
112
|
+
const states = incoming.map((edge) => edgeState(edge, results));
|
|
113
|
+
if (states.includes("unresolved"))
|
|
114
|
+
continue;
|
|
115
|
+
if (states.includes("blocked"))
|
|
116
|
+
skip(result, "upstream_failed", log);
|
|
117
|
+
else if (incoming.length > 0 && !states.includes("active"))
|
|
118
|
+
skip(result, "inactive", log);
|
|
119
|
+
else {
|
|
120
|
+
result.status = "runnable";
|
|
121
|
+
log.emit({ type: "node_runnable", nodeId: result.id });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function nodeOutput(result) {
|
|
126
|
+
// Failed predecessors can have no valid response; preserve their error separately.
|
|
127
|
+
const output = { output: result.output ?? "" };
|
|
128
|
+
if (result.decision !== undefined)
|
|
129
|
+
output.decision = result.decision;
|
|
130
|
+
if (result.model !== undefined)
|
|
131
|
+
output.model = result.model;
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
function predecessorOutputs(graph, id, results) {
|
|
135
|
+
const activeIds = new Set(graph.incoming
|
|
136
|
+
.get(id)
|
|
137
|
+
.filter((edge) => edgeState(edge, results) === "active")
|
|
138
|
+
.map((edge) => edge.from));
|
|
139
|
+
// A predecessor appears once even if more than one edge connects the same pair.
|
|
140
|
+
return [...activeIds].map((nodeId) => ({
|
|
141
|
+
nodeId,
|
|
142
|
+
...nodeOutput(results.get(nodeId)),
|
|
143
|
+
...(results.get(nodeId).error ? { error: { ...results.get(nodeId).error } } : {}),
|
|
144
|
+
...(results.get(nodeId).workspace ? { workspace: { ...results.get(nodeId).workspace } } : {}),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
function checkResponse(response) {
|
|
148
|
+
if (!response ||
|
|
149
|
+
typeof response.output !== "string" ||
|
|
150
|
+
(response.model !== undefined &&
|
|
151
|
+
(typeof response.model !== "string" || !response.model.trim()))) {
|
|
152
|
+
throw new RunError("INVALID_RESPONSE", "Runner must return a textual output and an optional model name");
|
|
153
|
+
}
|
|
154
|
+
if (response.usage !== undefined &&
|
|
155
|
+
(!response.usage ||
|
|
156
|
+
!Number.isSafeInteger(response.usage.inputTokens) ||
|
|
157
|
+
response.usage.inputTokens < 0 ||
|
|
158
|
+
!Number.isSafeInteger(response.usage.outputTokens) ||
|
|
159
|
+
response.usage.outputTokens < 0)) {
|
|
160
|
+
throw new RunError("INVALID_RESPONSE", "Token usage must contain non-negative integer inputTokens/outputTokens");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function runNode(request, result, runner, timeoutMs, graphSignal, graphDeadline, log, workspaces) {
|
|
164
|
+
result.status = "running";
|
|
165
|
+
result.startedAt = Date.now();
|
|
166
|
+
const started = performance.now();
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const signal = AbortSignal.any([graphSignal, controller.signal]);
|
|
169
|
+
const nodeTimeout = new RunError("NODE_TIMEOUT", `Node '${result.id}' exceeded ${timeoutMs}ms`);
|
|
170
|
+
const timer = Number.isFinite(timeoutMs)
|
|
171
|
+
? setTimeout(() => controller.abort(nodeTimeout), timeoutMs)
|
|
172
|
+
: undefined;
|
|
173
|
+
let onAbort;
|
|
174
|
+
const aborted = new Promise((_, reject) => {
|
|
175
|
+
onAbort = () => reject(signal.reason);
|
|
176
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
177
|
+
if (signal.aborted)
|
|
178
|
+
onAbort();
|
|
179
|
+
});
|
|
180
|
+
void aborted.catch(() => { });
|
|
181
|
+
let acceptingWrites = true;
|
|
182
|
+
const writes = new Set();
|
|
183
|
+
let merge;
|
|
184
|
+
let acceptingDecisions = true;
|
|
185
|
+
let decision;
|
|
186
|
+
let decisionError;
|
|
187
|
+
// Capture choices separately: the runner cannot change the declared tool enum.
|
|
188
|
+
const choices = request.node.type === "decision" ? [...request.node.choices] : undefined;
|
|
189
|
+
const invocation = { ...request, signal };
|
|
190
|
+
invocation.withWorkspaceWrite = async (operation) => {
|
|
191
|
+
signal.throwIfAborted();
|
|
192
|
+
if (!acceptingWrites)
|
|
193
|
+
throw new Error("Node has finished; further writes are unavailable");
|
|
194
|
+
const pending = Promise.resolve().then(() => {
|
|
195
|
+
signal.throwIfAborted();
|
|
196
|
+
return operation();
|
|
197
|
+
});
|
|
198
|
+
writes.add(pending);
|
|
199
|
+
try {
|
|
200
|
+
return await pending;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
writes.delete(pending);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (Number.isFinite(timeoutMs) || Number.isFinite(graphDeadline)) {
|
|
207
|
+
invocation.deadlines = {
|
|
208
|
+
...(Number.isFinite(timeoutMs) ? { node: started + timeoutMs } : {}),
|
|
209
|
+
...(Number.isFinite(graphDeadline) ? { graph: graphDeadline } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (choices) {
|
|
213
|
+
invocation.decide = (...values) => {
|
|
214
|
+
if (!acceptingDecisions || signal.aborted) {
|
|
215
|
+
throw new RunError("INVALID_DECISION", "decide called after invocation ended");
|
|
216
|
+
}
|
|
217
|
+
const choice = values[0];
|
|
218
|
+
if (values.length !== 1 ||
|
|
219
|
+
typeof choice !== "string" ||
|
|
220
|
+
!choices.includes(choice) ||
|
|
221
|
+
decision !== undefined) {
|
|
222
|
+
decisionError = new RunError("INVALID_DECISION", "decide must be called exactly once with one declared choice");
|
|
223
|
+
throw decisionError;
|
|
224
|
+
}
|
|
225
|
+
decision = choice;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function checkDeadline() {
|
|
229
|
+
signal.throwIfAborted();
|
|
230
|
+
const now = performance.now();
|
|
231
|
+
const error = now >= graphDeadline
|
|
232
|
+
? new RunError("GRAPH_TIMEOUT", "Graph deadline exceeded")
|
|
233
|
+
: now - started >= timeoutMs
|
|
234
|
+
? nodeTimeout
|
|
235
|
+
: undefined;
|
|
236
|
+
if (error) {
|
|
237
|
+
controller.abort(error);
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
checkDeadline();
|
|
243
|
+
if (request.node.type === "merge") {
|
|
244
|
+
merge = await workspaces.beginMerge(invocation, request.predecessors.map(value => value.nodeId));
|
|
245
|
+
invocation.merge = { sources: structuredClone(merge.sources),
|
|
246
|
+
...(merge.sourceStatus ? { sourceStatus: structuredClone(merge.sourceStatus) } : {}), finish: merge.finish };
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
invocation.workspace = await workspaces.prepare(invocation);
|
|
250
|
+
}
|
|
251
|
+
invocation.workspace = structuredClone(invocation.workspace);
|
|
252
|
+
if (invocation.workspace?.sourceRoot) {
|
|
253
|
+
const gitRequest = { ...invocation, node: structuredClone(invocation.node), workspace: structuredClone(invocation.workspace) };
|
|
254
|
+
invocation.git = (args, input) => workspaces.git(gitRequest, args, input);
|
|
255
|
+
}
|
|
256
|
+
// The rejection handler remains attached if a non-cooperative runner finishes late.
|
|
257
|
+
const response = await Promise.race([
|
|
258
|
+
aborted,
|
|
259
|
+
Promise.resolve().then(() => {
|
|
260
|
+
// Timer callbacks can be starved. Never start a provider call after its deadline.
|
|
261
|
+
checkDeadline();
|
|
262
|
+
log.emit({
|
|
263
|
+
type: "node_started",
|
|
264
|
+
nodeId: result.id,
|
|
265
|
+
...(result.model !== undefined ? { model: result.model } : {}),
|
|
266
|
+
});
|
|
267
|
+
for (const predecessor of request.predecessors) {
|
|
268
|
+
log.emit({
|
|
269
|
+
type: "handoff",
|
|
270
|
+
from: predecessor.nodeId,
|
|
271
|
+
to: result.id,
|
|
272
|
+
output: predecessor.output,
|
|
273
|
+
...(predecessor.decision !== undefined
|
|
274
|
+
? { decision: predecessor.decision }
|
|
275
|
+
: {}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
checkDeadline();
|
|
279
|
+
return runner(invocation);
|
|
280
|
+
}),
|
|
281
|
+
]);
|
|
282
|
+
acceptingDecisions = false;
|
|
283
|
+
checkDeadline();
|
|
284
|
+
checkResponse(response);
|
|
285
|
+
result.output = response.output;
|
|
286
|
+
if (response.model !== undefined)
|
|
287
|
+
result.model = response.model;
|
|
288
|
+
if (response.usage !== undefined)
|
|
289
|
+
result.usage = { ...response.usage };
|
|
290
|
+
if (decisionError)
|
|
291
|
+
throw decisionError;
|
|
292
|
+
if (choices && decision === undefined) {
|
|
293
|
+
throw new RunError("DECISION_REQUIRED", `Decision node '${result.id}' did not call decide`);
|
|
294
|
+
}
|
|
295
|
+
result.status = "completed";
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
result.status = "failed";
|
|
299
|
+
result.error = describeError(error);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
acceptingDecisions = false;
|
|
303
|
+
acceptingWrites = false;
|
|
304
|
+
await Promise.allSettled(writes);
|
|
305
|
+
if (merge) {
|
|
306
|
+
try {
|
|
307
|
+
await merge.complete(result.status === "completed");
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
result.status = "failed";
|
|
311
|
+
result.error = { code: "MERGE_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (decision !== undefined)
|
|
315
|
+
result.decision = decision;
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
signal.removeEventListener("abort", onAbort);
|
|
318
|
+
result.finishedAt = Date.now();
|
|
319
|
+
result.latencyMs = performance.now() - started;
|
|
320
|
+
const details = {
|
|
321
|
+
nodeId: result.id,
|
|
322
|
+
latencyMs: result.latencyMs,
|
|
323
|
+
...(result.output !== undefined ? { output: result.output } : {}),
|
|
324
|
+
...(result.decision !== undefined ? { decision: result.decision } : {}),
|
|
325
|
+
...(result.model !== undefined ? { model: result.model } : {}),
|
|
326
|
+
...(result.usage !== undefined ? { usage: result.usage } : {}),
|
|
327
|
+
};
|
|
328
|
+
log.emit(result.error
|
|
329
|
+
? { type: "node_failed", ...details, error: result.error }
|
|
330
|
+
: { type: "node_completed", ...details, output: result.output });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Submit a complete DAG; core may append a final merge node for pending worktrees. */
|
|
334
|
+
export async function braid(input, options) {
|
|
335
|
+
validateOptions(options);
|
|
336
|
+
const graph = compileGraph(input);
|
|
337
|
+
// Snapshot options as well as the graph before any asynchronous work.
|
|
338
|
+
const { runner, defaultModel, signal } = options;
|
|
339
|
+
const log = createEventLog(options.onEvent);
|
|
340
|
+
const maxConcurrency = options.maxConcurrency ?? 4;
|
|
341
|
+
const nodeTimeoutMs = options.nodeTimeoutMs ?? 60_000;
|
|
342
|
+
const graphTimeoutMs = options.graphTimeoutMs ?? 300_000;
|
|
343
|
+
const startedAt = Date.now();
|
|
344
|
+
const started = performance.now();
|
|
345
|
+
const graphDeadline = started + graphTimeoutMs;
|
|
346
|
+
const runId = crypto.randomUUID();
|
|
347
|
+
const execution = { runId, rootRunId: runId };
|
|
348
|
+
const edges = graph.edges;
|
|
349
|
+
log.emit({
|
|
350
|
+
type: "graph_created",
|
|
351
|
+
nodeCount: graph.nodes.length,
|
|
352
|
+
edgeCount: edges.length,
|
|
353
|
+
});
|
|
354
|
+
for (const node of graph.nodes) {
|
|
355
|
+
const model = node.model ?? defaultModel;
|
|
356
|
+
log.emit({
|
|
357
|
+
type: "node_created",
|
|
358
|
+
nodeId: node.id,
|
|
359
|
+
nodeType: node.type,
|
|
360
|
+
...(model !== undefined ? { model } : {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
for (const edge of edges) {
|
|
364
|
+
log.emit({
|
|
365
|
+
type: "edge_created",
|
|
366
|
+
from: edge.from,
|
|
367
|
+
to: edge.to,
|
|
368
|
+
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
const results = new Map(graph.nodes.map((node) => {
|
|
372
|
+
const result = { id: node.id, status: "pending" };
|
|
373
|
+
const model = node.model ?? defaultModel;
|
|
374
|
+
if (model !== undefined)
|
|
375
|
+
result.model = model;
|
|
376
|
+
return [node.id, result];
|
|
377
|
+
}));
|
|
378
|
+
const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
|
|
379
|
+
if (workspace.mode === "read-only")
|
|
380
|
+
return;
|
|
381
|
+
const node = results.get(workspace.nodeId);
|
|
382
|
+
if (node)
|
|
383
|
+
node.workspace = { ...workspace };
|
|
384
|
+
log.emit({ type: "workspace_updated", workspace: { ...workspace } });
|
|
385
|
+
});
|
|
386
|
+
const running = new Map();
|
|
387
|
+
let automaticMergeAdded = false;
|
|
388
|
+
let cleanupError;
|
|
389
|
+
const controller = new AbortController();
|
|
390
|
+
const graphTimeout = new RunError("GRAPH_TIMEOUT", `Graph exceeded ${graphTimeoutMs}ms`);
|
|
391
|
+
const onCancel = () => controller.abort(new RunError("CANCELLED", "Graph cancelled by caller"));
|
|
392
|
+
signal?.addEventListener("abort", onCancel, { once: true });
|
|
393
|
+
if (signal?.aborted)
|
|
394
|
+
onCancel();
|
|
395
|
+
const timer = Number.isFinite(graphTimeoutMs)
|
|
396
|
+
? setTimeout(() => controller.abort(graphTimeout), graphTimeoutMs)
|
|
397
|
+
: undefined;
|
|
398
|
+
try {
|
|
399
|
+
while (true) {
|
|
400
|
+
if (performance.now() >= graphDeadline)
|
|
401
|
+
controller.abort(graphTimeout);
|
|
402
|
+
if (controller.signal.aborted) {
|
|
403
|
+
const reason = describeError(controller.signal.reason).code === "CANCELLED"
|
|
404
|
+
? "cancelled"
|
|
405
|
+
: "graph_timeout";
|
|
406
|
+
for (const result of results.values()) {
|
|
407
|
+
if (result.status === "pending" || result.status === "runnable")
|
|
408
|
+
skip(result, reason, log);
|
|
409
|
+
}
|
|
410
|
+
await Promise.all(running.values());
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
resolveDependencies(graph, results, log);
|
|
414
|
+
for (const node of graph.topologicalOrder) {
|
|
415
|
+
if (running.size >= maxConcurrency)
|
|
416
|
+
break;
|
|
417
|
+
const result = results.get(node.id);
|
|
418
|
+
if (result.status !== "runnable")
|
|
419
|
+
continue;
|
|
420
|
+
// A merge owns the source checkout and may remove predecessors. Let
|
|
421
|
+
// currently running consumers finish first, and admit no workers during it.
|
|
422
|
+
if ([...running.keys()].some(id => graph.nodes.find(value => value.id === id)?.type === "merge"))
|
|
423
|
+
break;
|
|
424
|
+
if (node.type === "merge" && running.size > 0)
|
|
425
|
+
continue;
|
|
426
|
+
const predecessors = predecessorOutputs(graph, node.id, results);
|
|
427
|
+
const request = {
|
|
428
|
+
goal: graph.goal,
|
|
429
|
+
node: structuredClone(node),
|
|
430
|
+
predecessors,
|
|
431
|
+
execution: { ...execution },
|
|
432
|
+
};
|
|
433
|
+
if (result.model !== undefined)
|
|
434
|
+
request.model = result.model;
|
|
435
|
+
const task = runNode(request, result, runner, nodeTimeoutMs, controller.signal, graphDeadline, log, workspaces).finally(() => {
|
|
436
|
+
running.delete(node.id);
|
|
437
|
+
});
|
|
438
|
+
running.set(node.id, task);
|
|
439
|
+
}
|
|
440
|
+
if (running.size === 0) {
|
|
441
|
+
const pending = workspaces.pending();
|
|
442
|
+
if (!automaticMergeAdded && pending.length) {
|
|
443
|
+
automaticMergeAdded = true;
|
|
444
|
+
let id = "__braid_merge__";
|
|
445
|
+
while (results.has(id))
|
|
446
|
+
id += "_";
|
|
447
|
+
const node = { type: "merge", id, prompt: "Review every remaining node checkpoint, including partial work from failed nodes. Decide whether to integrate each change using git merge, cherry-pick, apply, or another available local operation. Preserve the user's existing changes. Call finish_merge to account for every source." };
|
|
448
|
+
graph.nodes.push(node);
|
|
449
|
+
graph.topologicalOrder.push(node);
|
|
450
|
+
graph.incoming.set(id, pending.map(from => ({ from, to: id })));
|
|
451
|
+
graph.outgoing.set(id, []);
|
|
452
|
+
for (const edge of graph.incoming.get(id)) {
|
|
453
|
+
graph.edges.push(edge);
|
|
454
|
+
graph.outgoing.get(edge.from).push(edge);
|
|
455
|
+
}
|
|
456
|
+
results.set(id, { id, status: "pending", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
457
|
+
log.emit({ type: "node_created", nodeId: id, nodeType: "merge", ...(defaultModel ? { model: defaultModel } : {}) });
|
|
458
|
+
for (const edge of graph.incoming.get(id))
|
|
459
|
+
log.emit({ type: "edge_created", ...edge });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
await Promise.race(running.values());
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
clearTimeout(timer);
|
|
469
|
+
signal?.removeEventListener("abort", onCancel);
|
|
470
|
+
try {
|
|
471
|
+
await workspaces.archivePending("Run ended before an agent integrated these changes; recover them from checkpointRef");
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
await workspaces.close();
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
cleanupError ??= { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
const terminalOutputs = Object.fromEntries(graph.nodes
|
|
484
|
+
.filter((node) => results.get(node.id).status === "completed" &&
|
|
485
|
+
!graph.outgoing
|
|
486
|
+
.get(node.id)
|
|
487
|
+
.some((edge) => edgeState(edge, results) === "active"))
|
|
488
|
+
.map((node) => [node.id, nodeOutput(results.get(node.id))]));
|
|
489
|
+
const usage = { inputTokens: 0, outputTokens: 0 };
|
|
490
|
+
let usageReportedNodes = 0;
|
|
491
|
+
for (const result of results.values()) {
|
|
492
|
+
if (result.usage) {
|
|
493
|
+
usage.inputTokens += result.usage.inputTokens;
|
|
494
|
+
usage.outputTokens += result.usage.outputTokens;
|
|
495
|
+
usageReportedNodes++;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const error = cleanupError ?? (controller.signal.aborted
|
|
499
|
+
? describeError(controller.signal.reason)
|
|
500
|
+
: [...results.values()].find((result) => result.status === "failed")?.error);
|
|
501
|
+
const result = {
|
|
502
|
+
status: error ? "failed" : "completed",
|
|
503
|
+
terminalOutputs,
|
|
504
|
+
events: [],
|
|
505
|
+
nodes: Object.fromEntries(results),
|
|
506
|
+
...(Object.values(workspaces.all()).some(value => value.mode !== "read-only") ? { workspaces: workspaces.all() } : {}),
|
|
507
|
+
metadata: {
|
|
508
|
+
...execution,
|
|
509
|
+
startedAt,
|
|
510
|
+
finishedAt: Date.now(),
|
|
511
|
+
latencyMs: performance.now() - started,
|
|
512
|
+
usage,
|
|
513
|
+
usageReportedNodes,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
if (error) {
|
|
517
|
+
result.error = { ...error };
|
|
518
|
+
log.emit({
|
|
519
|
+
type: "graph_failed",
|
|
520
|
+
error: { ...error },
|
|
521
|
+
terminalNodeIds: Object.keys(terminalOutputs),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
log.emit({
|
|
526
|
+
type: "graph_completed",
|
|
527
|
+
terminalNodeIds: Object.keys(terminalOutputs),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
result.events = Object.freeze(log.events);
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export class GraphValidationError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "GraphValidationError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
function requireValid(condition, message) {
|
|
8
|
+
if (!condition)
|
|
9
|
+
throw new GraphValidationError(message);
|
|
10
|
+
}
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function text(value) {
|
|
15
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
16
|
+
}
|
|
17
|
+
function fields(value, allowed, label) {
|
|
18
|
+
for (const key of Object.keys(value)) {
|
|
19
|
+
requireValid(allowed.includes(key), `${label}: unsupported field '${key}'`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Throws before execution for malformed graphs, references, choices, or cycles. */
|
|
23
|
+
export function validateGraph(input) {
|
|
24
|
+
compileGraph(input);
|
|
25
|
+
}
|
|
26
|
+
export function compileGraph(input) {
|
|
27
|
+
requireValid(isRecord(input), "Graph must be an object");
|
|
28
|
+
fields(input, ["goal", "nodes", "edges"], "Graph");
|
|
29
|
+
requireValid(text(input.goal), "Graph goal must be a non-empty string");
|
|
30
|
+
requireValid(Array.isArray(input.nodes) && input.nodes.length > 0, "Graph needs at least one node");
|
|
31
|
+
requireValid(Array.isArray(input.edges), "Graph edges must be an array");
|
|
32
|
+
const byId = new Map();
|
|
33
|
+
for (const node of input.nodes) {
|
|
34
|
+
requireValid(isRecord(node), "Node must be an object");
|
|
35
|
+
requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
|
|
36
|
+
fields(node, node.type === "decision"
|
|
37
|
+
? ["type", "id", "prompt", "model", "choices"]
|
|
38
|
+
: ["type", "id", "prompt", "model"], "Node");
|
|
39
|
+
requireValid(text(node.id), "Node id must be a non-empty string");
|
|
40
|
+
requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
|
|
41
|
+
requireValid(text(node.prompt) || (node.type === "merge" && node.prompt === undefined), `Node '${node.id}' needs a non-empty prompt`);
|
|
42
|
+
requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
|
|
43
|
+
const common = {
|
|
44
|
+
id: node.id,
|
|
45
|
+
prompt: node.prompt ?? "Review all predecessor changes, decide how to integrate them into the source repository, and account for every source with finish_merge.",
|
|
46
|
+
...(node.model !== undefined ? { model: node.model } : {}),
|
|
47
|
+
};
|
|
48
|
+
if (node.type === "decision") {
|
|
49
|
+
requireValid(Array.isArray(node.choices) && node.choices.length > 0, `Decision '${node.id}' needs at least one choice`);
|
|
50
|
+
const choices = [...node.choices];
|
|
51
|
+
requireValid(choices.every(text), `Invalid choice on '${node.id}'`);
|
|
52
|
+
requireValid(new Set(choices).size === choices.length, `Duplicate choices on '${node.id}'`);
|
|
53
|
+
byId.set(node.id, { type: "decision", ...common, choices });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
byId.set(node.id, { type: node.type, ...common });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const edges = [];
|
|
60
|
+
const incoming = new Map();
|
|
61
|
+
const outgoing = new Map();
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const id of byId.keys()) {
|
|
64
|
+
incoming.set(id, []);
|
|
65
|
+
outgoing.set(id, []);
|
|
66
|
+
}
|
|
67
|
+
for (const edge of input.edges) {
|
|
68
|
+
requireValid(isRecord(edge), "Edge must be an object");
|
|
69
|
+
fields(edge, ["from", "to", "choice"], "Edge");
|
|
70
|
+
requireValid(text(edge.from) && byId.has(edge.from), `Missing source node '${edge.from}'`);
|
|
71
|
+
requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
|
|
72
|
+
if (edge.choice !== undefined) {
|
|
73
|
+
const source = byId.get(edge.from);
|
|
74
|
+
requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
|
|
75
|
+
requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
|
|
76
|
+
}
|
|
77
|
+
const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null]);
|
|
78
|
+
requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
|
|
79
|
+
seen.add(key);
|
|
80
|
+
const snapshot = {
|
|
81
|
+
from: edge.from,
|
|
82
|
+
to: edge.to,
|
|
83
|
+
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
84
|
+
};
|
|
85
|
+
edges.push(snapshot);
|
|
86
|
+
incoming.get(edge.to).push(snapshot);
|
|
87
|
+
outgoing.get(edge.from).push(snapshot);
|
|
88
|
+
}
|
|
89
|
+
// Kahn's algorithm is iterative, including for very deep graphs.
|
|
90
|
+
const nodes = [...byId.values()];
|
|
91
|
+
const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).length]));
|
|
92
|
+
const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
|
|
93
|
+
for (let i = 0; i < topologicalOrder.length; i++) {
|
|
94
|
+
for (const edge of outgoing.get(topologicalOrder[i].id)) {
|
|
95
|
+
const count = remaining.get(edge.to) - 1;
|
|
96
|
+
remaining.set(edge.to, count);
|
|
97
|
+
if (count === 0)
|
|
98
|
+
topologicalOrder.push(byId.get(edge.to));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle");
|
|
102
|
+
return {
|
|
103
|
+
goal: input.goal,
|
|
104
|
+
nodes,
|
|
105
|
+
topologicalOrder,
|
|
106
|
+
edges,
|
|
107
|
+
incoming,
|
|
108
|
+
outgoing,
|
|
109
|
+
};
|
|
110
|
+
}
|