@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/CHANGELOG.md +172 -20
- package/CONTRIBUTING.md +9 -0
- package/README.md +232 -191
- package/ROADMAP.md +77 -27
- package/SECURITY.md +30 -4
- package/dist/adapters/openai.js +4 -4
- package/dist/budgets.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/merge-tools.d.ts +1 -1
- package/dist/merge-tools.js +8 -8
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +392 -242
- package/dist/types.d.ts +139 -20
- package/dist/validate.d.ts +7 -1
- package/dist/validate.js +140 -15
- package/dist/workspaces.d.ts +14 -4
- package/dist/workspaces.js +161 -116
- package/docs/benchmark.md +3 -0
- package/docs/compatibility.md +47 -11
- package/docs/examples.md +2 -1
- package/docs/execution-control.md +160 -0
- package/docs/releasing.md +66 -1
- package/docs/repository-settings.md +34 -0
- package/docs/resource-limits.md +13 -5
- package/examples/execution-control.ts +38 -0
- package/examples/failure-handling.ts +2 -2
- package/package.json +11 -4
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ export interface ExecuteNode {
|
|
|
3
3
|
id: string;
|
|
4
4
|
prompt: string;
|
|
5
5
|
model?: string;
|
|
6
|
-
/**
|
|
6
|
+
/** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
|
|
7
|
+
notifyOnCompletion?: boolean;
|
|
8
|
+
/** Optional failures remain in history; true aborts the entire run on failure. */
|
|
9
|
+
requireSuccess?: boolean;
|
|
10
|
+
/** Hold this execution's outgoing dependencies until explicitly resumed. */
|
|
11
|
+
pauseAfter?: boolean;
|
|
12
|
+
/** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
|
|
7
13
|
workspace?: "read-only" | "worktree";
|
|
8
14
|
}
|
|
9
15
|
export interface DecisionNode {
|
|
@@ -12,20 +18,52 @@ export interface DecisionNode {
|
|
|
12
18
|
prompt: string;
|
|
13
19
|
choices: readonly string[];
|
|
14
20
|
model?: string;
|
|
15
|
-
/**
|
|
21
|
+
/** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
|
|
22
|
+
notifyOnCompletion?: boolean;
|
|
23
|
+
/** Optional failures remain in history; true aborts the entire run on failure. */
|
|
24
|
+
requireSuccess?: boolean;
|
|
25
|
+
/** Hold this execution's outgoing dependencies until explicitly resumed. */
|
|
26
|
+
pauseAfter?: boolean;
|
|
27
|
+
/** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */
|
|
16
28
|
workspace?: "read-only" | "worktree";
|
|
17
29
|
}
|
|
18
30
|
export interface MergeNode {
|
|
19
31
|
type: "merge";
|
|
20
32
|
id: string;
|
|
21
|
-
/** Defaults to
|
|
33
|
+
/** Defaults to combining selected predecessor checkpoints in a new worktree. */
|
|
22
34
|
prompt?: string;
|
|
23
35
|
model?: string;
|
|
36
|
+
/** Ask the parent adapter for a completion reminder on success or failure. Default: false. */
|
|
37
|
+
notifyOnCompletion?: boolean;
|
|
38
|
+
/** Optional failures remain in history; true aborts the entire run on failure. */
|
|
39
|
+
requireSuccess?: boolean;
|
|
40
|
+
/** Hold this execution's outgoing dependencies until explicitly resumed. */
|
|
41
|
+
pauseAfter?: boolean;
|
|
24
42
|
}
|
|
25
|
-
export
|
|
43
|
+
export interface IntegrateNode extends Omit<MergeNode, "type"> {
|
|
44
|
+
type: "integrate";
|
|
45
|
+
}
|
|
46
|
+
export type BraidNode = ExecuteNode | DecisionNode | MergeNode | IntegrateNode;
|
|
47
|
+
/** A reference to a template in this graph submission; values are inserted literally. */
|
|
48
|
+
export interface PromptTemplateReference {
|
|
49
|
+
template: string;
|
|
50
|
+
variables: Readonly<Record<string, string>>;
|
|
51
|
+
}
|
|
52
|
+
export type NodePrompt = string | PromptTemplateReference;
|
|
53
|
+
/** Submission nodes may use templates; ModelRequest.node always has a string prompt. */
|
|
54
|
+
export type BraidInputNode = (Omit<ExecuteNode, "prompt"> & {
|
|
55
|
+
prompt: NodePrompt;
|
|
56
|
+
}) | (Omit<DecisionNode, "prompt"> & {
|
|
57
|
+
prompt: NodePrompt;
|
|
58
|
+
}) | (Omit<MergeNode, "prompt"> & {
|
|
59
|
+
prompt?: NodePrompt;
|
|
60
|
+
}) | (Omit<IntegrateNode, "prompt"> & {
|
|
61
|
+
prompt?: NodePrompt;
|
|
62
|
+
});
|
|
26
63
|
export interface NodeWorkspace {
|
|
27
64
|
nodeId: string;
|
|
28
|
-
|
|
65
|
+
executionId?: string;
|
|
66
|
+
mode: "read-only" | "worktree" | "integrate";
|
|
29
67
|
workingDirectory: string;
|
|
30
68
|
worktreeRoot?: string;
|
|
31
69
|
sourceRoot?: string;
|
|
@@ -35,11 +73,13 @@ export interface NodeWorkspace {
|
|
|
35
73
|
checkpointCommit?: string;
|
|
36
74
|
/** Source checkout snapshot captured before a merge agent receives write access. */
|
|
37
75
|
backupRef?: string;
|
|
76
|
+
/** Agent decisions for this merge/integrate invocation; sources remain reusable. */
|
|
77
|
+
dispositions?: MergeDisposition[];
|
|
38
78
|
state: "preparing" | "ready" | "integrated" | "discarded" | "archived" | "failed";
|
|
39
79
|
reason?: string;
|
|
40
80
|
}
|
|
41
81
|
export interface MergeDisposition {
|
|
42
|
-
|
|
82
|
+
executionId: string;
|
|
43
83
|
disposition: "integrated" | "discarded" | "archived";
|
|
44
84
|
reason: string;
|
|
45
85
|
}
|
|
@@ -48,7 +88,7 @@ export interface GitPreview {
|
|
|
48
88
|
truncated: boolean;
|
|
49
89
|
}
|
|
50
90
|
export interface MergeSource extends NodeWorkspace {
|
|
51
|
-
/** Inspection-only summaries relative to
|
|
91
|
+
/** Inspection-only summaries relative to the job’s initial snapshot; omitted by custom runners. */
|
|
52
92
|
changes?: {
|
|
53
93
|
files: string[];
|
|
54
94
|
filesTruncated: boolean;
|
|
@@ -70,11 +110,23 @@ export interface Edge {
|
|
|
70
110
|
to: string;
|
|
71
111
|
/** Omit for an unconditional edge, including from a decision node. */
|
|
72
112
|
choice?: string;
|
|
113
|
+
/** Explicit feedback edge into the named loop's entry. */
|
|
114
|
+
feedback?: string;
|
|
115
|
+
/** Pin a dependency to a historical execution rather than the current round. */
|
|
116
|
+
executionId?: string;
|
|
117
|
+
}
|
|
118
|
+
export interface LoopDefinition {
|
|
119
|
+
id: string;
|
|
120
|
+
entry: string;
|
|
121
|
+
maxIterations: number;
|
|
73
122
|
}
|
|
74
123
|
export interface BraidInput {
|
|
75
124
|
goal: string;
|
|
76
|
-
nodes: readonly
|
|
125
|
+
nodes: readonly BraidInputNode[];
|
|
77
126
|
edges: readonly Edge[];
|
|
127
|
+
/** Named {{variable}} templates, expanded and validated before execution. */
|
|
128
|
+
promptTemplates?: Readonly<Record<string, string>>;
|
|
129
|
+
loops?: readonly LoopDefinition[];
|
|
78
130
|
}
|
|
79
131
|
export type NodeStatus = "pending" | "runnable" | "running" | "completed" | "skipped" | "failed";
|
|
80
132
|
export interface TokenUsage {
|
|
@@ -88,14 +140,19 @@ export interface NodeOutput {
|
|
|
88
140
|
}
|
|
89
141
|
export interface PredecessorOutput extends NodeOutput {
|
|
90
142
|
nodeId: string;
|
|
143
|
+
executionId?: string;
|
|
91
144
|
/** Failed predecessors remain available on unconditional edges. */
|
|
92
145
|
error?: ExecutionError;
|
|
93
146
|
workspace?: NodeWorkspace;
|
|
94
147
|
}
|
|
95
148
|
export interface ExecutionContext {
|
|
96
149
|
runId: string;
|
|
97
|
-
/** Equal to runId
|
|
150
|
+
/** Equal to runId; reserved identity for future shared-root accounting. */
|
|
98
151
|
rootRunId: string;
|
|
152
|
+
executionId?: string;
|
|
153
|
+
revision?: number;
|
|
154
|
+
loopId?: string;
|
|
155
|
+
iteration?: number;
|
|
99
156
|
}
|
|
100
157
|
export interface ModelRequest {
|
|
101
158
|
goal: string;
|
|
@@ -114,7 +171,7 @@ export interface ModelRequest {
|
|
|
114
171
|
workspace?: NodeWorkspace;
|
|
115
172
|
/** Rejects read-only writes; adapters must wrap mutating file tools so cleanup waits for in-flight writes. */
|
|
116
173
|
withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
117
|
-
/** Local Git operations: inspection for workers, integration commands for merge nodes. */
|
|
174
|
+
/** Local Git operations: inspection for workers, integration commands for merge/integrate nodes. */
|
|
118
175
|
git?: (args: string[], input?: string) => Promise<GitResult>;
|
|
119
176
|
/** Merge nodes must account for every source before returning their final answer. */
|
|
120
177
|
merge?: {
|
|
@@ -133,10 +190,14 @@ export interface ModelResponse {
|
|
|
133
190
|
}
|
|
134
191
|
/** Each call starts a fresh conversation and exposes only the adapter’s declared capabilities. */
|
|
135
192
|
export type ModelRunner = (request: ModelRequest) => Promise<ModelResponse>;
|
|
136
|
-
/** Frozen snapshots in emission order
|
|
193
|
+
/** Frozen snapshots in emission order, including graph revisions and execution identities. */
|
|
137
194
|
export type ExecutionEvent = Readonly<{
|
|
138
195
|
sequence: number;
|
|
139
196
|
timestamp: number;
|
|
197
|
+
executionId?: string;
|
|
198
|
+
revision?: number;
|
|
199
|
+
loopId?: string;
|
|
200
|
+
iteration?: number;
|
|
140
201
|
} & ({
|
|
141
202
|
type: "graph_created";
|
|
142
203
|
nodeCount: number;
|
|
@@ -146,11 +207,23 @@ export type ExecutionEvent = Readonly<{
|
|
|
146
207
|
nodeId: string;
|
|
147
208
|
nodeType: BraidNode["type"];
|
|
148
209
|
model?: string;
|
|
149
|
-
} | {
|
|
210
|
+
} | ({
|
|
150
211
|
type: "edge_created";
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
212
|
+
} & Edge) | {
|
|
213
|
+
type: "graph_updated";
|
|
214
|
+
graph: BraidInput;
|
|
215
|
+
} | {
|
|
216
|
+
type: "execution_paused";
|
|
217
|
+
nodeId: string;
|
|
218
|
+
} | {
|
|
219
|
+
type: "execution_resumed";
|
|
220
|
+
nodeId: string;
|
|
221
|
+
} | {
|
|
222
|
+
type: "loop_started";
|
|
223
|
+
entry: string;
|
|
224
|
+
} | {
|
|
225
|
+
type: "loop_completed";
|
|
226
|
+
entry: string;
|
|
154
227
|
} | {
|
|
155
228
|
type: "workspace_updated";
|
|
156
229
|
workspace: Readonly<NodeWorkspace>;
|
|
@@ -163,6 +236,7 @@ export type ExecutionEvent = Readonly<{
|
|
|
163
236
|
to: string;
|
|
164
237
|
output: string;
|
|
165
238
|
decision?: string;
|
|
239
|
+
fromExecutionId?: string;
|
|
166
240
|
} | {
|
|
167
241
|
type: "node_started";
|
|
168
242
|
nodeId: string;
|
|
@@ -193,7 +267,7 @@ export type ExecutionEvent = Readonly<{
|
|
|
193
267
|
export interface BraidOptions {
|
|
194
268
|
runner: ModelRunner;
|
|
195
269
|
defaultModel?: string;
|
|
196
|
-
/** Source checkout
|
|
270
|
+
/** Source checkout for the initial snapshot and explicit integrate nodes. */
|
|
197
271
|
cwd?: string;
|
|
198
272
|
/** Positive integer. Defaults to 4. */
|
|
199
273
|
maxConcurrency?: number;
|
|
@@ -205,30 +279,36 @@ export interface BraidOptions {
|
|
|
205
279
|
signal?: AbortSignal;
|
|
206
280
|
/** Live observer. Throws/rejections are ignored; returned work is not awaited. */
|
|
207
281
|
onEvent?: (event: ExecutionEvent) => void;
|
|
282
|
+
/** Total admitted executions across loops and graph updates. Default: 1000. */
|
|
283
|
+
maxExecutions?: number;
|
|
208
284
|
}
|
|
209
285
|
export interface ExecutionError {
|
|
210
|
-
code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED";
|
|
286
|
+
code: "MODEL_ERROR" | "INVALID_RESPONSE" | "DECISION_REQUIRED" | "INVALID_DECISION" | "NODE_TIMEOUT" | "GRAPH_TIMEOUT" | "CANCELLED" | "MERGE_FAILED" | "CLEANUP_FAILED" | "CHECKPOINT_FAILED" | "WORKSPACE_MERGE_REQUIRED" | "EXECUTION_LIMIT" | "LOOP_LIMIT" | "REQUIRED_NODE_FAILED" | "SCHEDULING_ERROR";
|
|
211
287
|
message: string;
|
|
212
288
|
}
|
|
213
289
|
/** Output is absent if no valid response was received; status determines success. */
|
|
214
290
|
export interface NodeResult extends Partial<NodeOutput> {
|
|
215
291
|
id: string;
|
|
292
|
+
executionId?: string;
|
|
216
293
|
status: NodeStatus;
|
|
217
294
|
usage?: TokenUsage;
|
|
218
295
|
startedAt?: number;
|
|
219
296
|
finishedAt?: number;
|
|
220
297
|
latencyMs?: number;
|
|
221
298
|
error?: ExecutionError;
|
|
222
|
-
skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled";
|
|
299
|
+
skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled" | "run_failed";
|
|
223
300
|
workspace?: NodeWorkspace;
|
|
224
301
|
}
|
|
225
302
|
export interface BraidResult {
|
|
226
303
|
status: "completed" | "failed";
|
|
227
|
-
/**
|
|
304
|
+
/** Latest output per node among completed, unconsumed executions; exact IDs are in terminalExecutionIds. */
|
|
228
305
|
terminalOutputs: Record<string, NodeOutput>;
|
|
229
|
-
/** Immutable execution log, including graph
|
|
306
|
+
/** Immutable execution log, including graph revisions and runtime handoffs. */
|
|
230
307
|
events: readonly ExecutionEvent[];
|
|
231
308
|
nodes: Record<string, NodeResult>;
|
|
309
|
+
executions: Record<string, NodeExecution>;
|
|
310
|
+
terminalExecutionIds: string[];
|
|
311
|
+
revision: number;
|
|
232
312
|
workspaces?: Record<string, NodeWorkspace>;
|
|
233
313
|
error?: ExecutionError;
|
|
234
314
|
metadata: ExecutionContext & {
|
|
@@ -240,3 +320,42 @@ export interface BraidResult {
|
|
|
240
320
|
usageReportedNodes: number;
|
|
241
321
|
};
|
|
242
322
|
}
|
|
323
|
+
/** One immutable invocation definition, with a result updated only by that invocation. */
|
|
324
|
+
export interface NodeExecution extends NodeResult {
|
|
325
|
+
executionId: string;
|
|
326
|
+
node: BraidNode;
|
|
327
|
+
revision: number;
|
|
328
|
+
predecessorExecutionIds: string[];
|
|
329
|
+
loopId?: string;
|
|
330
|
+
iteration?: number;
|
|
331
|
+
}
|
|
332
|
+
export interface GraphUpdate {
|
|
333
|
+
expectedRevision: number;
|
|
334
|
+
upsertNodes?: readonly BraidInputNode[];
|
|
335
|
+
removeNodeIds?: readonly string[];
|
|
336
|
+
addEdges?: readonly Edge[];
|
|
337
|
+
removeEdges?: readonly Edge[];
|
|
338
|
+
promptTemplates?: Readonly<Record<string, string>>;
|
|
339
|
+
loops?: readonly LoopDefinition[];
|
|
340
|
+
/** Resume these completed executions atomically with this update. */
|
|
341
|
+
resume?: readonly string[];
|
|
342
|
+
}
|
|
343
|
+
export interface BraidSnapshot {
|
|
344
|
+
runId: string;
|
|
345
|
+
status: "running" | "waiting" | "finalizing" | "completed" | "failed";
|
|
346
|
+
revision: number;
|
|
347
|
+
graph: BraidInput;
|
|
348
|
+
nodes: Record<string, NodeResult>;
|
|
349
|
+
executions: Record<string, NodeExecution>;
|
|
350
|
+
pausedExecutionIds: string[];
|
|
351
|
+
/** Present as soon as a run-wide failure or cancellation starts draining work. */
|
|
352
|
+
error?: ExecutionError;
|
|
353
|
+
}
|
|
354
|
+
export interface BraidRun {
|
|
355
|
+
runId: string;
|
|
356
|
+
result: Promise<BraidResult>;
|
|
357
|
+
snapshot(): BraidSnapshot;
|
|
358
|
+
update(update: GraphUpdate): BraidSnapshot;
|
|
359
|
+
resume(executionIds: readonly string[], expectedRevision: number): BraidSnapshot;
|
|
360
|
+
cancel(): void;
|
|
361
|
+
}
|
package/dist/validate.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BraidInput, BraidNode, Edge } from "./types.js";
|
|
1
|
+
import type { BraidInput, BraidNode, Edge, LoopDefinition } from "./types.js";
|
|
2
2
|
export declare class GraphValidationError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
@@ -10,6 +10,12 @@ export interface Graph {
|
|
|
10
10
|
edges: Edge[];
|
|
11
11
|
incoming: Map<string, Edge[]>;
|
|
12
12
|
outgoing: Map<string, Edge[]>;
|
|
13
|
+
loops: Map<string, CompiledLoop>;
|
|
14
|
+
membership: Map<string, string>;
|
|
15
|
+
}
|
|
16
|
+
export interface CompiledLoop extends LoopDefinition {
|
|
17
|
+
feedback: Edge;
|
|
18
|
+
members: Set<string>;
|
|
13
19
|
}
|
|
14
20
|
/** Throws before execution for malformed graphs, references, choices, or cycles. */
|
|
15
21
|
export declare function validateGraph(input: BraidInput): void;
|
package/dist/validate.js
CHANGED
|
@@ -19,35 +19,103 @@ function fields(value, allowed, label) {
|
|
|
19
19
|
requireValid(allowed.includes(key), `${label}: unsupported field '${key}'`);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
function compileTemplates(value) {
|
|
23
|
+
const templates = new Map();
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return templates;
|
|
26
|
+
requireValid(isRecord(value), "Graph promptTemplates must be an object");
|
|
27
|
+
for (const [name, source] of Object.entries(value)) {
|
|
28
|
+
requireValid(text(name), "Prompt template name must be a non-empty string");
|
|
29
|
+
const label = `Prompt template '${name}'`;
|
|
30
|
+
requireValid(text(source), `${label} must be a non-empty string`);
|
|
31
|
+
const parts = [];
|
|
32
|
+
const variables = new Set();
|
|
33
|
+
let offset = 0;
|
|
34
|
+
while (offset < source.length) {
|
|
35
|
+
const open = source.indexOf("{{", offset);
|
|
36
|
+
const close = source.indexOf("}}", offset);
|
|
37
|
+
requireValid(close === -1 || (open !== -1 && close > open), `${label} has an unmatched '}}'`);
|
|
38
|
+
if (open === -1) {
|
|
39
|
+
parts.push(source.slice(offset));
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
requireValid(close !== -1, `${label} has an unclosed '{{'`);
|
|
43
|
+
const variable = source.slice(open + 2, close).trim();
|
|
44
|
+
requireValid(/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable), `${label} has invalid variable '${variable}'`);
|
|
45
|
+
parts.push(source.slice(offset, open), { variable });
|
|
46
|
+
variables.add(variable);
|
|
47
|
+
offset = close + 2;
|
|
48
|
+
}
|
|
49
|
+
templates.set(name, { parts, variables });
|
|
50
|
+
}
|
|
51
|
+
return templates;
|
|
52
|
+
}
|
|
53
|
+
function renderPrompt(value, nodeId, templates) {
|
|
54
|
+
const nodeLabel = `Node '${nodeId}'`;
|
|
55
|
+
if (typeof value === "string") {
|
|
56
|
+
requireValid(text(value), `${nodeLabel} needs a non-empty prompt`);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
requireValid(isRecord(value), `${nodeLabel} needs a non-empty prompt or template reference`);
|
|
60
|
+
fields(value, ["template", "variables"], `${nodeLabel} prompt`);
|
|
61
|
+
requireValid(text(value.template), `${nodeLabel} prompt needs a non-empty template reference`);
|
|
62
|
+
const label = `${nodeLabel} prompt template '${value.template}'`;
|
|
63
|
+
const template = templates.get(value.template);
|
|
64
|
+
requireValid(template, `${label} is unknown`);
|
|
65
|
+
requireValid(isRecord(value.variables), `${label} variables must be an object`);
|
|
66
|
+
const variables = new Map();
|
|
67
|
+
for (const [name, variable] of Object.entries(value.variables)) {
|
|
68
|
+
requireValid(typeof variable === "string", `${label} variable '${name}' must be a string`);
|
|
69
|
+
requireValid(template.variables.has(name), `${label} has unused variable '${name}'`);
|
|
70
|
+
variables.set(name, variable);
|
|
71
|
+
}
|
|
72
|
+
for (const name of template.variables) {
|
|
73
|
+
requireValid(variables.has(name), `${label} is missing variable '${name}'`);
|
|
74
|
+
}
|
|
75
|
+
// One pass over the parsed template: inserted values are never parsed or evaluated.
|
|
76
|
+
const rendered = template.parts.map(part => typeof part === "string" ? part : variables.get(part.variable)).join("");
|
|
77
|
+
requireValid(text(rendered), `${label} renders an empty prompt`);
|
|
78
|
+
return rendered;
|
|
79
|
+
}
|
|
22
80
|
/** Throws before execution for malformed graphs, references, choices, or cycles. */
|
|
23
81
|
export function validateGraph(input) {
|
|
24
82
|
compileGraph(input);
|
|
25
83
|
}
|
|
26
84
|
export function compileGraph(input) {
|
|
27
85
|
requireValid(isRecord(input), "Graph must be an object");
|
|
28
|
-
fields(input, ["goal", "nodes", "edges"], "Graph");
|
|
86
|
+
fields(input, ["goal", "nodes", "edges", "promptTemplates", "loops"], "Graph");
|
|
29
87
|
requireValid(text(input.goal), "Graph goal must be a non-empty string");
|
|
30
88
|
requireValid(Array.isArray(input.nodes) && input.nodes.length > 0, "Graph needs at least one node");
|
|
31
89
|
requireValid(Array.isArray(input.edges), "Graph edges must be an array");
|
|
90
|
+
const templates = compileTemplates(input.promptTemplates);
|
|
32
91
|
const byId = new Map();
|
|
33
92
|
for (const node of input.nodes) {
|
|
34
93
|
requireValid(isRecord(node), "Node must be an object");
|
|
35
|
-
requireValid(node.type === "execute" || node.type === "decision" || node.type === "merge", "Unknown node type");
|
|
94
|
+
requireValid(node.type === "execute" || node.type === "decision" || (node.type === "merge" || node.type === "integrate"), "Unknown node type");
|
|
36
95
|
fields(node, node.type === "decision"
|
|
37
|
-
? ["type", "id", "prompt", "model", "choices", "workspace"]
|
|
96
|
+
? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
|
|
38
97
|
: node.type === "execute"
|
|
39
|
-
? ["type", "id", "prompt", "model", "workspace"]
|
|
40
|
-
: ["type", "id", "prompt", "model"], "Node");
|
|
98
|
+
? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
|
|
99
|
+
: ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter"], "Node");
|
|
41
100
|
requireValid(text(node.id), "Node id must be a non-empty string");
|
|
42
101
|
requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
|
|
43
|
-
|
|
102
|
+
const prompt = (node.type === "merge" || node.type === "integrate") && node.prompt === undefined
|
|
103
|
+
? (node.type === "merge" ? "Merge selected predecessor results into this new isolated worktree. Resolve conflicts and account for every source with finish_merge." : "Integrate selected predecessor results into the invoking checkout. Preserve user changes and account for every source with finish_merge.")
|
|
104
|
+
: renderPrompt(node.prompt, node.id, templates);
|
|
44
105
|
requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
|
|
45
|
-
const workspace = node.type === "merge" ? undefined : node.workspace;
|
|
106
|
+
const workspace = (node.type === "merge" || node.type === "integrate") ? undefined : node.workspace;
|
|
107
|
+
requireValid(node.notifyOnCompletion === undefined || typeof node.notifyOnCompletion === "boolean", `Invalid notifyOnCompletion on '${node.id}': expected a boolean`);
|
|
46
108
|
requireValid(workspace === undefined || workspace === "read-only" || workspace === "worktree", `Invalid workspace on '${node.id}'`);
|
|
109
|
+
for (const field of ["requireSuccess", "pauseAfter"]) {
|
|
110
|
+
requireValid(node[field] === undefined || typeof node[field] === "boolean", `Invalid ${field} on '${node.id}': expected a boolean`);
|
|
111
|
+
}
|
|
47
112
|
const common = {
|
|
48
113
|
id: node.id,
|
|
49
|
-
prompt
|
|
114
|
+
prompt,
|
|
50
115
|
...(node.model !== undefined ? { model: node.model } : {}),
|
|
116
|
+
...(node.notifyOnCompletion !== undefined ? { notifyOnCompletion: node.notifyOnCompletion } : {}),
|
|
117
|
+
...(node.requireSuccess !== undefined ? { requireSuccess: node.requireSuccess } : {}),
|
|
118
|
+
...(node.pauseAfter !== undefined ? { pauseAfter: node.pauseAfter } : {}),
|
|
51
119
|
...(workspace !== undefined ? { workspace } : {}),
|
|
52
120
|
};
|
|
53
121
|
if (node.type === "decision") {
|
|
@@ -71,39 +139,94 @@ export function compileGraph(input) {
|
|
|
71
139
|
}
|
|
72
140
|
for (const edge of input.edges) {
|
|
73
141
|
requireValid(isRecord(edge), "Edge must be an object");
|
|
74
|
-
fields(edge, ["from", "to", "choice"], "Edge");
|
|
75
|
-
requireValid(
|
|
142
|
+
fields(edge, ["from", "to", "choice", "feedback", "executionId"], "Edge");
|
|
143
|
+
requireValid(edge.executionId === undefined || text(edge.executionId), "Invalid edge executionId");
|
|
144
|
+
requireValid(edge.feedback === undefined || text(edge.feedback), "Invalid edge feedback");
|
|
145
|
+
requireValid(!edge.feedback || !edge.executionId, "Feedback cannot pin an execution");
|
|
146
|
+
requireValid(text(edge.from) && (byId.has(edge.from) || edge.executionId !== undefined), `Missing source node '${edge.from}'`);
|
|
76
147
|
requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
|
|
77
|
-
|
|
148
|
+
requireValid(edge.choice === undefined || text(edge.choice), "Invalid edge choice");
|
|
149
|
+
if (edge.choice !== undefined && edge.executionId === undefined) {
|
|
78
150
|
const source = byId.get(edge.from);
|
|
79
151
|
requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
|
|
80
152
|
requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
|
|
81
153
|
}
|
|
82
|
-
const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null]);
|
|
154
|
+
const key = JSON.stringify([edge.from, edge.to, edge.choice ?? null, edge.feedback ?? null, edge.executionId ?? null]);
|
|
83
155
|
requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
|
|
84
156
|
seen.add(key);
|
|
85
157
|
const snapshot = {
|
|
86
158
|
from: edge.from,
|
|
87
159
|
to: edge.to,
|
|
88
160
|
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
161
|
+
...(edge.feedback !== undefined ? { feedback: edge.feedback } : {}),
|
|
162
|
+
...(edge.executionId !== undefined ? { executionId: edge.executionId } : {}),
|
|
89
163
|
};
|
|
90
164
|
edges.push(snapshot);
|
|
91
165
|
incoming.get(edge.to).push(snapshot);
|
|
92
|
-
outgoing.get(edge.from)
|
|
166
|
+
outgoing.get(edge.from)?.push(snapshot);
|
|
93
167
|
}
|
|
94
168
|
// Kahn's algorithm is iterative, including for very deep graphs.
|
|
95
169
|
const nodes = [...byId.values()];
|
|
96
|
-
const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).length]));
|
|
170
|
+
const remaining = new Map(nodes.map((node) => [node.id, incoming.get(node.id).filter(edge => !edge.feedback && !edge.executionId).length]));
|
|
97
171
|
const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
|
|
98
172
|
for (let i = 0; i < topologicalOrder.length; i++) {
|
|
99
173
|
for (const edge of outgoing.get(topologicalOrder[i].id)) {
|
|
174
|
+
if (edge.feedback || edge.executionId)
|
|
175
|
+
continue;
|
|
100
176
|
const count = remaining.get(edge.to) - 1;
|
|
101
177
|
remaining.set(edge.to, count);
|
|
102
178
|
if (count === 0)
|
|
103
179
|
topologicalOrder.push(byId.get(edge.to));
|
|
104
180
|
}
|
|
105
181
|
}
|
|
106
|
-
requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle");
|
|
182
|
+
requireValid(topologicalOrder.length === nodes.length, "Graph contains a cycle without a declared feedback edge");
|
|
183
|
+
const loops = new Map();
|
|
184
|
+
const membership = new Map();
|
|
185
|
+
requireValid(input.loops === undefined || Array.isArray(input.loops), "Graph loops must be an array");
|
|
186
|
+
const reachable = (start, reverse = false) => {
|
|
187
|
+
const visited = new Set();
|
|
188
|
+
const queue = [start];
|
|
189
|
+
while (queue.length) {
|
|
190
|
+
const id = queue.pop();
|
|
191
|
+
if (visited.has(id))
|
|
192
|
+
continue;
|
|
193
|
+
visited.add(id);
|
|
194
|
+
for (const edge of (reverse ? incoming : outgoing).get(id) ?? []) {
|
|
195
|
+
if (!edge.feedback && !edge.executionId)
|
|
196
|
+
queue.push(reverse ? edge.from : edge.to);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return visited;
|
|
200
|
+
};
|
|
201
|
+
for (const loop of input.loops ?? []) {
|
|
202
|
+
requireValid(isRecord(loop), "Loop must be an object");
|
|
203
|
+
fields(loop, ["id", "entry", "maxIterations"], "Loop");
|
|
204
|
+
requireValid(text(loop.id) && !loops.has(loop.id), "Loop ids must be non-empty and unique");
|
|
205
|
+
requireValid(text(loop.entry) && byId.has(loop.entry), `Unknown loop entry '${loop.entry}'`);
|
|
206
|
+
requireValid(typeof loop.maxIterations === "number" && Number.isSafeInteger(loop.maxIterations) && loop.maxIterations > 0, `Loop '${loop.id}' needs a positive maxIterations`);
|
|
207
|
+
const feedback = edges.filter(edge => edge.feedback === loop.id);
|
|
208
|
+
requireValid(feedback.length === 1, `Loop '${loop.id}' needs exactly one feedback edge`);
|
|
209
|
+
const back = feedback[0];
|
|
210
|
+
const decision = byId.get(back.from);
|
|
211
|
+
requireValid(back.to === loop.entry && decision.type === "decision" && back.choice !== undefined, `Loop '${loop.id}' feedback must route a decision choice to its entry`);
|
|
212
|
+
requireValid(decision.choices.some(choice => choice !== back.choice), `Loop '${loop.id}' needs an exit choice`);
|
|
213
|
+
const ancestors = reachable(back.from, true);
|
|
214
|
+
const members = new Set([...reachable(loop.entry)].filter(id => ancestors.has(id)));
|
|
215
|
+
requireValid(members.has(loop.entry) && members.has(back.from), `Loop '${loop.id}' entry must reach its feedback decision`);
|
|
216
|
+
for (const id of members) {
|
|
217
|
+
requireValid(!membership.has(id), "Nested or overlapping loops are unsupported");
|
|
218
|
+
membership.set(id, loop.id);
|
|
219
|
+
}
|
|
220
|
+
for (const edge of edges) {
|
|
221
|
+
if (edge.feedback || edge.executionId)
|
|
222
|
+
continue;
|
|
223
|
+
requireValid(!(!members.has(edge.from) && members.has(edge.to) && edge.to !== loop.entry), `Loop '${loop.id}' has multiple entries`);
|
|
224
|
+
requireValid(!(members.has(edge.from) && !members.has(edge.to) && edge.from !== back.from), `Loop '${loop.id}' must exit through its feedback decision`);
|
|
225
|
+
}
|
|
226
|
+
loops.set(loop.id, { id: loop.id, entry: loop.entry, maxIterations: loop.maxIterations, feedback: back, members });
|
|
227
|
+
}
|
|
228
|
+
for (const edge of edges)
|
|
229
|
+
requireValid(!edge.feedback || loops.has(edge.feedback), `Unknown feedback loop '${edge.feedback}'`);
|
|
107
230
|
return {
|
|
108
231
|
goal: input.goal,
|
|
109
232
|
nodes,
|
|
@@ -111,5 +234,7 @@ export function compileGraph(input) {
|
|
|
111
234
|
edges,
|
|
112
235
|
incoming,
|
|
113
236
|
outgoing,
|
|
237
|
+
loops,
|
|
238
|
+
membership,
|
|
114
239
|
};
|
|
115
240
|
}
|
package/dist/workspaces.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GitResult, MergeDisposition, MergeSource, ModelRequest, NodeWorkspace, SourceCheckoutStatus } from "./types.js";
|
|
2
|
-
/**
|
|
2
|
+
/** Each execution owns a fresh worktree derived from immutable predecessor checkpoints. */
|
|
3
3
|
export declare class GitWorkspaces {
|
|
4
4
|
private readonly cwd;
|
|
5
5
|
private readonly onWorkspace?;
|
|
@@ -7,17 +7,23 @@ export declare class GitWorkspaces {
|
|
|
7
7
|
private records;
|
|
8
8
|
private locations;
|
|
9
9
|
private allocated;
|
|
10
|
+
private mergeParents;
|
|
10
11
|
constructor(cwd: string, onWorkspace?: ((workspace: NodeWorkspace) => void) | undefined);
|
|
11
12
|
private report;
|
|
12
13
|
private sourceRoot;
|
|
13
14
|
private snapshot;
|
|
14
|
-
|
|
15
|
+
/** Freeze the job's root snapshot before any invocation can write to the checkout. */
|
|
16
|
+
initialize(runId: string): Promise<void>;
|
|
17
|
+
private id;
|
|
18
|
+
private inputCommit;
|
|
19
|
+
prepare(request: ModelRequest, merge?: boolean): Promise<NodeWorkspace>;
|
|
20
|
+
/** Seal before releasing any downstream execution, including failures with partial writes. */
|
|
21
|
+
seal(request: ModelRequest): Promise<void>;
|
|
22
|
+
private sealCheckpoint;
|
|
15
23
|
all(): Record<string, NodeWorkspace>;
|
|
16
24
|
pending(): string[];
|
|
17
25
|
/** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
|
|
18
26
|
private checkpoint;
|
|
19
|
-
/** Run only after declared nodes settle, so consumers retain their source paths. */
|
|
20
|
-
discardUnchanged(): Promise<void>;
|
|
21
27
|
private release;
|
|
22
28
|
/** Only archive/remove here. Choosing merge/cherry-pick/apply always belongs to the agent. */
|
|
23
29
|
archivePending(reason: string): Promise<void>;
|
|
@@ -30,3 +36,7 @@ export declare class GitWorkspaces {
|
|
|
30
36
|
complete: (success: boolean) => Promise<void>;
|
|
31
37
|
}>;
|
|
32
38
|
}
|
|
39
|
+
export declare class WorkspaceInputError extends Error {
|
|
40
|
+
}
|
|
41
|
+
export declare class WorkspaceCheckpointError extends Error {
|
|
42
|
+
}
|