@chrok/braid 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { compileGraph } from "./validate.js";
2
- import { GitWorkspaces } from "./workspaces.js";
2
+ import { GitWorkspaces, WorkspaceInputError, WorkspaceCheckpointError } from "./workspaces.js";
3
3
  import { resolve } from "node:path";
4
4
  class RunError extends Error {
5
5
  code;
@@ -11,7 +11,7 @@ class RunError extends Error {
11
11
  }
12
12
  function describeError(error) {
13
13
  return {
14
- code: error instanceof RunError ? error.code : "MODEL_ERROR",
14
+ code: error instanceof RunError ? error.code : error instanceof WorkspaceInputError ? "WORKSPACE_MERGE_REQUIRED" : "MODEL_ERROR",
15
15
  message: error instanceof Error
16
16
  ? error.message
17
17
  : typeof error === "string"
@@ -39,6 +39,8 @@ function validateOptions(options) {
39
39
  if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
40
40
  throw new TypeError("maxConcurrency must be a positive integer");
41
41
  }
42
+ if (!Number.isSafeInteger(options.maxExecutions ?? 1000) || (options.maxExecutions ?? 1000) < 1)
43
+ throw new TypeError("maxExecutions must be a positive integer");
42
44
  for (const key of ["nodeTimeoutMs", "graphTimeoutMs"]) {
43
45
  const value = options[key];
44
46
  if (value !== undefined &&
@@ -59,6 +61,14 @@ function createEventLog(onEvent) {
59
61
  sequence: ++sequence,
60
62
  timestamp: Date.now(),
61
63
  });
64
+ const nested = [event];
65
+ while (nested.length) {
66
+ const value = nested.pop();
67
+ if (value && typeof value === "object") {
68
+ Object.freeze(value);
69
+ nested.push(...Object.values(value));
70
+ }
71
+ }
62
72
  if ("error" in event)
63
73
  Object.freeze(event.error);
64
74
  if ("usage" in event && event.usage)
@@ -80,46 +90,14 @@ function createEventLog(onEvent) {
80
90
  },
81
91
  };
82
92
  }
83
- function edgeState(edge, results) {
84
- const source = results.get(edge.from);
93
+ function edgeState(edge, source) {
94
+ if (!source)
95
+ return "unresolved";
85
96
  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
- }
97
+ case "completed": return edge.choice === undefined || edge.choice === source.decision ? "active" : "inactive";
98
+ case "failed": return edge.choice === undefined ? "active" : "blocked";
99
+ case "skipped": return source.skipReason === "inactive" ? "inactive" : "blocked";
100
+ default: return "unresolved";
123
101
  }
124
102
  }
125
103
  function nodeOutput(result) {
@@ -131,19 +109,6 @@ function nodeOutput(result) {
131
109
  output.model = result.model;
132
110
  return output;
133
111
  }
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
112
  function checkResponse(response) {
148
113
  if (!response ||
149
114
  typeof response.output !== "string" ||
@@ -160,7 +125,7 @@ function checkResponse(response) {
160
125
  throw new RunError("INVALID_RESPONSE", "Token usage must contain non-negative integer inputTokens/outputTokens");
161
126
  }
162
127
  }
163
- async function runNode(request, result, runner, timeoutMs, graphSignal, graphDeadline, log, workspaces) {
128
+ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDeadline, log, workspaces, fail) {
164
129
  result.status = "running";
165
130
  result.startedAt = Date.now();
166
131
  const started = performance.now();
@@ -179,6 +144,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
179
144
  });
180
145
  void aborted.catch(() => { });
181
146
  let acceptingWrites = true;
147
+ let runnerStarted = false;
182
148
  let writableWorkspace = false;
183
149
  const writes = new Set();
184
150
  let merge;
@@ -243,8 +209,8 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
243
209
  }
244
210
  try {
245
211
  checkDeadline();
246
- if (request.node.type === "merge") {
247
- merge = await workspaces.beginMerge(invocation, request.predecessors.map(value => value.nodeId));
212
+ if (request.node.type === "merge" || request.node.type === "integrate") {
213
+ merge = await workspaces.beginMerge(invocation, request.predecessors.map(value => value.executionId));
248
214
  invocation.merge = { sources: structuredClone(merge.sources),
249
215
  ...(merge.sourceStatus ? { sourceStatus: structuredClone(merge.sourceStatus) } : {}), finish: merge.finish };
250
216
  }
@@ -272,6 +238,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
272
238
  log.emit({
273
239
  type: "handoff",
274
240
  from: predecessor.nodeId,
241
+ ...(predecessor.executionId ? { fromExecutionId: predecessor.executionId } : {}),
275
242
  to: result.id,
276
243
  output: predecessor.output,
277
244
  ...(predecessor.decision !== undefined
@@ -280,6 +247,7 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
280
247
  });
281
248
  }
282
249
  checkDeadline();
250
+ runnerStarted = true;
283
251
  return runner(invocation);
284
252
  }),
285
253
  ]);
@@ -301,20 +269,40 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
301
269
  catch (error) {
302
270
  result.status = "failed";
303
271
  result.error = describeError(error);
272
+ fail(result.error, !runnerStarted && !(error instanceof WorkspaceInputError) && !signal.aborted);
304
273
  }
305
274
  finally {
306
275
  acceptingDecisions = false;
307
276
  acceptingWrites = false;
308
277
  await Promise.allSettled(writes);
278
+ if (result.status === "completed") {
279
+ try {
280
+ checkDeadline();
281
+ }
282
+ catch (error) {
283
+ result.status = "failed";
284
+ result.error = describeError(error);
285
+ fail(result.error);
286
+ }
287
+ }
309
288
  if (merge) {
310
289
  try {
311
290
  await merge.complete(result.status === "completed");
312
291
  }
313
292
  catch (error) {
314
293
  result.status = "failed";
315
- result.error = { code: "MERGE_FAILED", message: error instanceof Error ? error.message : String(error) };
294
+ result.error = { code: error instanceof WorkspaceCheckpointError ? "CHECKPOINT_FAILED" : "MERGE_FAILED", message: error instanceof Error ? error.message : String(error) };
295
+ fail(result.error, error instanceof WorkspaceCheckpointError);
316
296
  }
317
297
  }
298
+ try {
299
+ await workspaces.seal(invocation);
300
+ }
301
+ catch (error) {
302
+ result.status = "failed";
303
+ result.error = { code: "CHECKPOINT_FAILED", message: error instanceof Error ? error.message : String(error) };
304
+ fail(result.error, true);
305
+ }
318
306
  if (decision !== undefined)
319
307
  result.decision = decision;
320
308
  clearTimeout(timer);
@@ -334,218 +322,380 @@ async function runNode(request, result, runner, timeoutMs, graphSignal, graphDea
334
322
  : { type: "node_completed", ...details, output: result.output });
335
323
  }
336
324
  }
337
- /** Submit a complete DAG; core may append a final merge node for pending worktrees. */
325
+ const settled = (record) => ["completed", "failed", "skipped"].includes(record.status);
326
+ const edgeKey = (edge) => JSON.stringify([edge.from, edge.to, edge.choice, edge.feedback, edge.executionId]);
327
+ /** Execute a graph to completion. Use startBraid for live edits and pause/resume. */
338
328
  export async function braid(input, options) {
329
+ return startBraid(input, options).result;
330
+ }
331
+ /** Definitions are mutable; each admitted execution captures its definition and inputs. */
332
+ export function startBraid(input, options) {
339
333
  validateOptions(options);
340
- const graph = compileGraph(input);
341
- // Snapshot options as well as the graph before any asynchronous work.
334
+ let definition = structuredClone(input);
335
+ let graph = compileGraph(definition);
336
+ if (graph.edges.some(edge => edge.executionId))
337
+ throw new TypeError("Initial edges cannot reference historical executions");
342
338
  const { runner, defaultModel, signal } = options;
343
- const log = createEventLog(options.onEvent);
344
339
  const maxConcurrency = options.maxConcurrency ?? 4;
340
+ const maxExecutions = options.maxExecutions ?? 1000;
345
341
  const nodeTimeoutMs = options.nodeTimeoutMs ?? 60_000;
346
342
  const graphTimeoutMs = options.graphTimeoutMs ?? 300_000;
347
343
  const startedAt = Date.now();
348
344
  const started = performance.now();
349
345
  const graphDeadline = started + graphTimeoutMs;
350
346
  const runId = crypto.randomUUID();
351
- const execution = { runId, rootRunId: runId };
352
- const edges = graph.edges;
353
- log.emit({
354
- type: "graph_created",
355
- nodeCount: graph.nodes.length,
356
- edgeCount: edges.length,
357
- });
358
- for (const node of graph.nodes) {
359
- const model = node.model ?? defaultModel;
360
- log.emit({
361
- type: "node_created",
362
- nodeId: node.id,
363
- nodeType: node.type,
364
- ...(model !== undefined ? { model } : {}),
365
- });
366
- }
367
- for (const edge of edges) {
368
- log.emit({
369
- type: "edge_created",
370
- from: edge.from,
371
- to: edge.to,
372
- ...(edge.choice !== undefined ? { choice: edge.choice } : {}),
373
- });
374
- }
375
- const results = new Map(graph.nodes.map((node) => {
376
- const result = { id: node.id, status: "pending" };
377
- const model = node.model ?? defaultModel;
378
- if (model !== undefined)
379
- result.model = model;
380
- return [node.id, result];
381
- }));
382
- const explicitReadOnly = new Set(graph.nodes.filter(node => node.type !== "merge" && node.workspace === "read-only").map(node => node.id));
383
- // Preserve the existing event/result shape for implicit non-Git read-only runs.
384
- const reportWorkspace = (workspace) => workspace.mode !== "read-only" || explicitReadOnly.has(workspace.nodeId);
385
- const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
386
- if (!reportWorkspace(workspace))
387
- return;
388
- const node = results.get(workspace.nodeId);
389
- if (node)
390
- node.workspace = { ...workspace };
391
- log.emit({ type: "workspace_updated", workspace: { ...workspace } });
392
- });
347
+ const log = createEventLog(options.onEvent);
348
+ const executions = new Map();
349
+ const latest = new Map();
350
+ const roots = new Map();
351
+ const loops = new Map();
352
+ const paused = new Set();
393
353
  const running = new Map();
394
- let automaticMergeAdded = false;
395
- let cleanupError;
396
354
  const controller = new AbortController();
355
+ let revision = 0;
356
+ let status = "running";
357
+ let finalNodes;
358
+ let wake;
359
+ let changed;
360
+ function resetWake() { changed = new Promise(resolve => { wake = resolve; }); }
361
+ resetWake();
362
+ controller.signal.addEventListener("abort", () => wake());
397
363
  const graphTimeout = new RunError("GRAPH_TIMEOUT", `Graph exceeded ${graphTimeoutMs}ms`);
398
- const onCancel = () => controller.abort(new RunError("CANCELLED", "Graph cancelled by caller"));
399
- signal?.addEventListener("abort", onCancel, { once: true });
364
+ const cancel = () => {
365
+ if (status !== "completed" && status !== "failed")
366
+ controller.abort(new RunError("CANCELLED", "Graph cancelled by caller"));
367
+ };
368
+ signal?.addEventListener("abort", cancel, { once: true });
400
369
  if (signal?.aborted)
401
- onCancel();
402
- const timer = Number.isFinite(graphTimeoutMs)
403
- ? setTimeout(() => controller.abort(graphTimeout), graphTimeoutMs)
404
- : undefined;
405
- try {
406
- while (true) {
407
- if (performance.now() >= graphDeadline)
408
- controller.abort(graphTimeout);
409
- if (controller.signal.aborted) {
410
- const reason = describeError(controller.signal.reason).code === "CANCELLED"
411
- ? "cancelled"
412
- : "graph_timeout";
413
- for (const result of results.values()) {
414
- if (result.status === "pending" || result.status === "runnable")
415
- skip(result, reason, log);
416
- }
417
- await Promise.all(running.values());
418
- break;
370
+ cancel();
371
+ const timer = Number.isFinite(graphTimeoutMs) ? setTimeout(() => controller.abort(graphTimeout), graphTimeoutMs) : undefined;
372
+ const context = (record) => ({ executionId: record.executionId, revision: record.revision,
373
+ ...(record.loopId ? { loopId: record.loopId, iteration: record.iteration } : {}) });
374
+ const workspaces = new GitWorkspaces(resolve(options.cwd ?? process.cwd()), workspace => {
375
+ const record = executions.get(workspace.executionId);
376
+ if (record)
377
+ record.workspace = { ...workspace };
378
+ log.emit({ type: "workspace_updated", workspace, ...(record ? context(record) : {}) });
379
+ });
380
+ function snapshot() {
381
+ const nodes = Object.fromEntries(graph.nodes.map(node => [node.id, latest.get(node.id) ?? { id: node.id, status: "pending" }]));
382
+ for (const [id, record] of latest)
383
+ Object.defineProperty(nodes, id, { value: record, enumerable: true, writable: true, configurable: true });
384
+ return structuredClone({ runId, status, revision, graph: definition, nodes: finalNodes ?? nodes,
385
+ executions: Object.fromEntries(executions), pausedExecutionIds: [...paused],
386
+ ...(controller.signal.aborted ? { error: describeError(controller.signal.reason) } : {}) });
387
+ }
388
+ function assertMutable(expectedRevision) {
389
+ if (status === "finalizing" || status === "completed" || status === "failed" || controller.signal.aborted)
390
+ throw new Error("This run is no longer accepting updates");
391
+ if (expectedRevision !== revision)
392
+ throw new Error(`Revision conflict: expected ${expectedRevision}, current ${revision}`);
393
+ }
394
+ function validateResume(ids) {
395
+ if (!Array.isArray(ids) || new Set(ids).size !== ids.length || ids.some(id => !paused.has(id)))
396
+ throw new Error("resume must contain unique paused execution IDs");
397
+ }
398
+ function release(ids) {
399
+ for (const id of ids)
400
+ paused.delete(id);
401
+ status = "running";
402
+ for (const id of ids) {
403
+ const record = executions.get(id);
404
+ log.emit({ type: "execution_resumed", nodeId: record.id, ...context(record) });
405
+ }
406
+ }
407
+ function reconcileLoops(previousGraph) {
408
+ // Preserve the already admitted activation when an edit moves a definition
409
+ // between scopes. Keep removed members until their original round drains.
410
+ const previousRecords = new Map();
411
+ for (const node of previousGraph?.nodes ?? []) {
412
+ const scope = previousGraph.membership.get(node.id);
413
+ const record = scope ? loops.get(scope)?.records.get(node.id) : roots.get(node.id);
414
+ if (record)
415
+ previousRecords.set(node.id, record);
416
+ }
417
+ for (const [id, state] of loops) {
418
+ const current = graph.loops.get(id);
419
+ for (const [nodeId, record] of state.records) {
420
+ if (!current?.members.has(nodeId) && !graph.membership.has(nodeId))
421
+ roots.set(nodeId, record);
419
422
  }
420
- resolveDependencies(graph, results, log);
421
- for (const node of graph.topologicalOrder) {
422
- if (running.size >= maxConcurrency)
423
- break;
424
- const result = results.get(node.id);
425
- if (result.status !== "runnable")
426
- continue;
427
- // A merge owns the source checkout and may remove predecessors. Let
428
- // currently running consumers finish first, and admit no workers during it.
429
- if ([...running.keys()].some(id => graph.nodes.find(value => value.id === id)?.type === "merge"))
430
- break;
431
- if (node.type === "merge" && running.size > 0)
432
- continue;
433
- const predecessors = predecessorOutputs(graph, node.id, results);
434
- const request = {
435
- goal: graph.goal,
436
- node: structuredClone(node),
437
- predecessors,
438
- execution: { ...execution },
439
- };
440
- if (result.model !== undefined)
441
- request.model = result.model;
442
- const task = runNode(request, result, runner, nodeTimeoutMs, controller.signal, graphDeadline, log, workspaces).finally(() => {
443
- running.delete(node.id);
444
- });
445
- running.set(node.id, task);
423
+ }
424
+ for (const [id, loop] of graph.loops) {
425
+ let state = loops.get(id);
426
+ if (!state) {
427
+ state = { iteration: 1, records: new Map(), done: false };
428
+ loops.set(id, state);
446
429
  }
447
- if (running.size === 0) {
448
- if (!automaticMergeAdded) {
449
- try {
450
- await workspaces.discardUnchanged();
430
+ for (const nodeId of loop.members) {
431
+ if (!state.records.has(nodeId) && previousGraph?.membership.get(nodeId) !== id) {
432
+ const record = previousRecords.get(nodeId) ?? roots.get(nodeId);
433
+ if (record) {
434
+ state.records.set(nodeId, record);
435
+ roots.delete(nodeId);
451
436
  }
452
- catch (error) {
453
- cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
454
- break;
455
- }
456
- // Checkpointing/cleanup can outlast the deadline or trigger cancellation.
457
- if (controller.signal.aborted || performance.now() >= graphDeadline)
458
- continue;
459
- }
460
- const pending = workspaces.pending();
461
- if (!automaticMergeAdded && pending.length) {
462
- automaticMergeAdded = true;
463
- let id = "__braid_merge__";
464
- while (results.has(id))
465
- id += "_";
466
- 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." };
467
- graph.nodes.push(node);
468
- graph.topologicalOrder.push(node);
469
- graph.incoming.set(id, pending.map(from => ({ from, to: id })));
470
- graph.outgoing.set(id, []);
471
- for (const edge of graph.incoming.get(id)) {
472
- graph.edges.push(edge);
473
- graph.outgoing.get(edge.from).push(edge);
474
- }
475
- results.set(id, { id, status: "pending", ...(defaultModel ? { model: defaultModel } : {}) });
476
- log.emit({ type: "node_created", nodeId: id, nodeType: "merge", ...(defaultModel ? { model: defaultModel } : {}) });
477
- for (const edge of graph.incoming.get(id))
478
- log.emit({ type: "edge_created", ...edge });
479
- continue;
480
437
  }
481
- break;
482
438
  }
483
- await Promise.race(running.values());
439
+ if ([...loop.members].some(nodeId => !state.records.has(nodeId)))
440
+ state.done = false;
484
441
  }
485
442
  }
486
- finally {
487
- clearTimeout(timer);
488
- signal?.removeEventListener("abort", onCancel);
489
- try {
490
- await workspaces.archivePending("Run ended before an agent integrated these changes; recover them from checkpointRef");
443
+ reconcileLoops();
444
+ function update(patch) {
445
+ assertMutable(patch.expectedRevision);
446
+ const allowed = ["expectedRevision", "upsertNodes", "removeNodeIds", "addEdges", "removeEdges", "promptTemplates", "loops", "resume"];
447
+ if (Object.keys(patch).some(key => !allowed.includes(key)))
448
+ throw new TypeError("Unknown graph update field");
449
+ for (const key of ["upsertNodes", "removeNodeIds", "addEdges", "removeEdges", "loops", "resume"])
450
+ if (patch[key] !== undefined && !Array.isArray(patch[key]))
451
+ throw new TypeError(`${key} must be an array`);
452
+ if (patch.removeNodeIds?.some(id => typeof id !== "string" || !id.trim()))
453
+ throw new TypeError("removeNodeIds must contain non-empty node IDs");
454
+ for (const edge of patch.removeEdges ?? []) {
455
+ if (!edge || typeof edge !== "object" || !edge.from || !edge.to ||
456
+ Object.entries(edge).some(([key, value]) => !["from", "to", "choice", "feedback", "executionId"].includes(key) || typeof value !== "string" || !value.trim()))
457
+ throw new TypeError("removeEdges must contain complete edge identities");
491
458
  }
492
- catch (error) {
493
- cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
459
+ validateResume(patch.resume ?? []);
460
+ const removed = new Set(patch.removeNodeIds ?? []);
461
+ const nodes = new Map(definition.nodes.filter(node => !removed.has(node.id)).map(node => [node.id, node]));
462
+ for (const node of patch.upsertNodes ?? [])
463
+ nodes.set(node.id, node);
464
+ const removedEdges = new Set((patch.removeEdges ?? []).map(edgeKey));
465
+ const edges = definition.edges.filter(edge => !removed.has(edge.from) && !removed.has(edge.to) && !removedEdges.has(edgeKey(edge)));
466
+ const candidate = structuredClone({ ...definition, nodes: [...nodes.values()], edges: [...edges, ...(patch.addEdges ?? [])],
467
+ ...(patch.promptTemplates !== undefined ? { promptTemplates: patch.promptTemplates } : {}),
468
+ ...(patch.loops !== undefined ? { loops: patch.loops } : {}) });
469
+ const compiled = compileGraph(candidate);
470
+ for (const edge of compiled.edges) {
471
+ if (!edge.executionId)
472
+ continue;
473
+ const record = executions.get(edge.executionId);
474
+ if (!record || record.id !== edge.from || !settled(record))
475
+ throw new TypeError(`Unknown or unfinished source execution '${edge.executionId}'`);
476
+ if (edge.choice && (record.node.type !== "decision" || !record.node.choices.includes(edge.choice)))
477
+ throw new TypeError(`Invalid choice for execution '${edge.executionId}'`);
494
478
  }
495
- try {
496
- await workspaces.close();
479
+ const previousGraph = graph;
480
+ definition = candidate;
481
+ graph = compiled;
482
+ revision++;
483
+ reconcileLoops(previousGraph);
484
+ // Commit all state before publishing callbacks, which may themselves submit an update.
485
+ for (const id of patch.resume ?? [])
486
+ paused.delete(id);
487
+ status = "running";
488
+ log.emit({ type: "graph_updated", graph: definition, revision });
489
+ for (const [id, loop] of graph.loops)
490
+ if (!previousGraph.loops.has(id))
491
+ log.emit({ type: "loop_started", loopId: id, entry: loop.entry, iteration: loops.get(id).iteration, revision });
492
+ for (const id of patch.resume ?? []) {
493
+ const record = executions.get(id);
494
+ log.emit({ type: "execution_resumed", nodeId: record.id, ...context(record) });
497
495
  }
498
- catch (error) {
499
- cleanupError ??= { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
496
+ wake();
497
+ return snapshot();
498
+ }
499
+ function frame(nodeId) {
500
+ const loopId = graph.membership.get(nodeId);
501
+ return loopId ? loops.get(loopId).records : roots;
502
+ }
503
+ function sourceFor(edge, target) {
504
+ if (edge.executionId)
505
+ return executions.get(edge.executionId);
506
+ const sourceLoop = graph.membership.get(edge.from);
507
+ if (sourceLoop && sourceLoop !== graph.membership.get(target) && !loops.get(sourceLoop).done)
508
+ return undefined;
509
+ return frame(edge.from).get(edge.from);
510
+ }
511
+ function dependencies(nodeId) {
512
+ const inputs = graph.incoming.get(nodeId).filter(edge => !edge.feedback).map(edge => ({ edge, record: sourceFor(edge, nodeId) }));
513
+ const states = inputs.map(({ edge, record }) => record && (running.has(record.executionId) || paused.has(record.executionId)) ? "unresolved" : edgeState(edge, record));
514
+ const predecessors = inputs.filter((_, index) => states[index] === "active").map(value => value.record);
515
+ const loopId = graph.membership.get(nodeId);
516
+ const loop = loopId ? graph.loops.get(loopId) : undefined;
517
+ const state = loopId ? loops.get(loopId) : undefined;
518
+ if (loop?.entry === nodeId && state?.previous) {
519
+ states.push("active");
520
+ predecessors.push(state.previous);
500
521
  }
522
+ return { states, predecessors: [...new Map(predecessors.map(record => [record.executionId, record])).values()] };
501
523
  }
502
- const terminalOutputs = Object.fromEntries(graph.nodes
503
- .filter((node) => results.get(node.id).status === "completed" &&
504
- !graph.outgoing
505
- .get(node.id)
506
- .some((edge) => edgeState(edge, results) === "active"))
507
- .map((node) => [node.id, nodeOutput(results.get(node.id))]));
508
- const usage = { inputTokens: 0, outputTokens: 0 };
509
- let usageReportedNodes = 0;
510
- for (const result of results.values()) {
511
- if (result.usage) {
512
- usage.inputTokens += result.usage.inputTokens;
513
- usage.outputTokens += result.usage.outputTokens;
514
- usageReportedNodes++;
524
+ function admit(nodeId, predecessors, skipReason) {
525
+ if (executions.size >= maxExecutions) {
526
+ controller.abort(new RunError("EXECUTION_LIMIT", `Graph exceeded ${maxExecutions} executions`));
527
+ return;
528
+ }
529
+ const node = graph.nodes.find(node => node.id === nodeId);
530
+ const loopId = graph.membership.get(node.id);
531
+ const record = {
532
+ id: node.id, executionId: crypto.randomUUID(), node: structuredClone(node), revision,
533
+ predecessorExecutionIds: predecessors.map(value => value.executionId), status: skipReason ? "skipped" : "runnable",
534
+ ...(loopId ? { loopId, iteration: loops.get(loopId).iteration } : {}),
535
+ ...(node.model ?? defaultModel ? { model: node.model ?? defaultModel } : {}),
536
+ };
537
+ executions.set(record.executionId, record);
538
+ latest.set(node.id, record);
539
+ frame(node.id).set(node.id, record);
540
+ if (skipReason) {
541
+ record.skipReason = skipReason;
542
+ record.finishedAt = Date.now();
543
+ log.emit({ type: "node_skipped", nodeId: record.id, reason: skipReason, ...context(record) });
544
+ return;
515
545
  }
546
+ const scopedLog = { events: log.events, emit: event => log.emit({ ...event, ...context(record) }) };
547
+ const request = {
548
+ goal: graph.goal, node: structuredClone(record.node), execution: { runId, rootRunId: runId, ...context(record) },
549
+ predecessors: predecessors.map(value => ({ nodeId: value.id, executionId: value.executionId, ...nodeOutput(value),
550
+ ...(value.error ? { error: { ...value.error } } : {}), ...(value.workspace ? { workspace: { ...value.workspace } } : {}) })),
551
+ ...(record.model ? { model: record.model } : {}),
552
+ };
553
+ // Reserve the slot before publishing events or invoking asynchronous runner code.
554
+ const task = Promise.resolve().then(async () => {
555
+ scopedLog.emit({ type: "node_runnable", nodeId: record.id });
556
+ await runNode(request, record, runner, nodeTimeoutMs, controller.signal, graphDeadline, scopedLog, workspaces, (error, infrastructure) => {
557
+ if (infrastructure)
558
+ controller.abort(new RunError(error.code, error.message));
559
+ else if (record.node.requireSuccess)
560
+ controller.abort(new RunError("REQUIRED_NODE_FAILED", `Required node '${record.id}' (${record.executionId}) failed: ${error.message}`));
561
+ });
562
+ if (record.node.pauseAfter && !controller.signal.aborted) {
563
+ paused.add(record.executionId);
564
+ scopedLog.emit({ type: "execution_paused", nodeId: record.id });
565
+ }
566
+ }).catch(error => {
567
+ controller.abort(new RunError("SCHEDULING_ERROR", error instanceof Error ? error.message : String(error)));
568
+ }).finally(() => { running.delete(record.executionId); wake(); });
569
+ running.set(record.executionId, task);
516
570
  }
517
- const error = cleanupError ?? (controller.signal.aborted
518
- ? describeError(controller.signal.reason)
519
- : [...results.values()].find((result) => result.status === "failed")?.error);
520
- const result = {
521
- status: error ? "failed" : "completed",
522
- terminalOutputs,
523
- events: [],
524
- nodes: Object.fromEntries(results),
525
- ...(Object.values(workspaces.all()).some(reportWorkspace) ? { workspaces: workspaces.all() } : {}),
526
- metadata: {
527
- ...execution,
528
- startedAt,
529
- finishedAt: Date.now(),
530
- latencyMs: performance.now() - started,
531
- usage,
532
- usageReportedNodes,
533
- },
534
- };
535
- if (error) {
536
- result.error = { ...error };
537
- log.emit({
538
- type: "graph_failed",
539
- error: { ...error },
540
- terminalNodeIds: Object.keys(terminalOutputs),
541
- });
571
+ function advanceLoops() {
572
+ for (const [id, loop] of graph.loops) {
573
+ const state = loops.get(id);
574
+ if (state.done || ![...loop.members].every(nodeId => state.records.has(nodeId)))
575
+ continue;
576
+ if ([...state.records.values()].some(record => !settled(record) || running.has(record.executionId) || paused.has(record.executionId)))
577
+ continue;
578
+ const decision = state.records.get(loop.feedback.from);
579
+ if (edgeState(loop.feedback, decision) === "active") {
580
+ if (state.iteration >= loop.maxIterations) {
581
+ controller.abort(new RunError("LOOP_LIMIT", `Loop '${id}' exceeded ${loop.maxIterations} iterations`));
582
+ return true;
583
+ }
584
+ state.iteration++;
585
+ state.previous = decision;
586
+ state.records = new Map();
587
+ log.emit({ type: "loop_started", loopId: id, iteration: state.iteration, entry: loop.entry, revision });
588
+ }
589
+ else {
590
+ state.done = true;
591
+ log.emit({ type: "loop_completed", loopId: id, iteration: state.iteration, entry: loop.entry, revision });
592
+ }
593
+ return true;
594
+ }
595
+ return false;
542
596
  }
543
- else {
544
- log.emit({
545
- type: "graph_completed",
546
- terminalNodeIds: Object.keys(terminalOutputs),
547
- });
597
+ async function execute() {
598
+ let cleanupError;
599
+ try {
600
+ log.emit({ type: "graph_created", nodeCount: graph.nodes.length, edgeCount: graph.edges.length, revision });
601
+ for (const node of graph.nodes)
602
+ log.emit({ type: "node_created", nodeId: node.id, nodeType: node.type, ...(node.model ?? defaultModel ? { model: node.model ?? defaultModel } : {}) });
603
+ for (const edge of graph.edges)
604
+ log.emit({ type: "edge_created", ...edge });
605
+ await workspaces.initialize(runId);
606
+ for (const [id, loop] of graph.loops)
607
+ log.emit({ type: "loop_started", loopId: id, iteration: 1, entry: loop.entry, revision });
608
+ while (true) {
609
+ if (performance.now() >= graphDeadline)
610
+ controller.abort(graphTimeout);
611
+ if (controller.signal.aborted) {
612
+ await Promise.all(running.values());
613
+ break;
614
+ }
615
+ resetWake();
616
+ let progressed = advanceLoops();
617
+ const schedulingRevision = revision;
618
+ for (const node of graph.topologicalOrder) {
619
+ if (controller.signal.aborted || revision !== schedulingRevision)
620
+ break;
621
+ const loopId = graph.membership.get(node.id);
622
+ if (loopId && loops.get(loopId).done || frame(node.id).has(node.id))
623
+ continue;
624
+ const { states, predecessors } = dependencies(node.id);
625
+ if (states.includes("unresolved"))
626
+ continue;
627
+ const reason = states.includes("blocked") ? "upstream_failed" : states.length && !states.includes("active") ? "inactive" : undefined;
628
+ if (!reason && running.size >= maxConcurrency)
629
+ continue;
630
+ admit(node.id, predecessors, reason);
631
+ progressed = true;
632
+ }
633
+ if (controller.signal.aborted || progressed || schedulingRevision !== revision)
634
+ continue;
635
+ if (running.size || paused.size) {
636
+ status = running.size ? "running" : "waiting";
637
+ await changed;
638
+ continue;
639
+ }
640
+ const unfinished = graph.nodes.some(node => !frame(node.id).has(node.id));
641
+ if (unfinished)
642
+ controller.abort(new RunError("SCHEDULING_ERROR", "The current graph cannot make progress"));
643
+ break;
644
+ }
645
+ }
646
+ catch (error) {
647
+ controller.abort(new RunError("SCHEDULING_ERROR", error instanceof Error ? error.message : String(error)));
648
+ await Promise.all(running.values());
649
+ }
650
+ finally {
651
+ status = "finalizing";
652
+ try {
653
+ await workspaces.archivePending("Execution checkpoint retained for inspection and future recovery");
654
+ }
655
+ catch (error) {
656
+ cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
657
+ }
658
+ try {
659
+ await workspaces.close();
660
+ }
661
+ catch (error) {
662
+ cleanupError ??= { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) };
663
+ }
664
+ clearTimeout(timer);
665
+ signal?.removeEventListener("abort", cancel);
666
+ }
667
+ const consumed = new Set([...executions.values()].flatMap(record => record.status === "skipped" ? [] : record.predecessorExecutionIds));
668
+ const terminals = [...executions.values()].filter(record => record.status === "completed" && !consumed.has(record.executionId));
669
+ const terminalOutputs = Object.fromEntries(terminals.map(record => [record.id, nodeOutput(record)]));
670
+ const usage = { inputTokens: 0, outputTokens: 0 };
671
+ let usageReportedNodes = 0;
672
+ for (const record of executions.values())
673
+ if (record.usage) {
674
+ usage.inputTokens += record.usage.inputTokens;
675
+ usage.outputTokens += record.usage.outputTokens;
676
+ usageReportedNodes++;
677
+ }
678
+ const error = cleanupError ?? (controller.signal.aborted ? describeError(controller.signal.reason) : undefined);
679
+ status = error ? "failed" : "completed";
680
+ const nodes = snapshot().nodes;
681
+ for (const node of Object.values(nodes))
682
+ if (node.status === "pending") {
683
+ node.status = "skipped";
684
+ node.skipReason = error?.code === "CANCELLED" ? "cancelled" : error?.code === "GRAPH_TIMEOUT" ? "graph_timeout" : "run_failed";
685
+ log.emit({ type: "node_skipped", nodeId: node.id, reason: node.skipReason });
686
+ }
687
+ finalNodes = structuredClone(nodes);
688
+ const result = {
689
+ status, terminalOutputs, nodes, executions: structuredClone(Object.fromEntries(executions)),
690
+ terminalExecutionIds: terminals.map(record => record.executionId), revision, events: [],
691
+ workspaces: workspaces.all(), ...(error ? { error } : {}),
692
+ metadata: { runId, rootRunId: runId, startedAt, finishedAt: Date.now(), latencyMs: performance.now() - started, usage, usageReportedNodes },
693
+ };
694
+ log.emit(error ? { type: "graph_failed", error, terminalNodeIds: Object.keys(terminalOutputs), revision }
695
+ : { type: "graph_completed", terminalNodeIds: Object.keys(terminalOutputs), revision });
696
+ result.events = Object.freeze(log.events);
697
+ return result;
548
698
  }
549
- result.events = Object.freeze(log.events);
550
- return result;
699
+ return { runId, result: Promise.resolve().then(execute), snapshot, update, cancel,
700
+ resume(ids, expectedRevision) { assertMutable(expectedRevision); validateResume(ids); release(ids); wake(); return snapshot(); } };
551
701
  }