@chrok/braid 0.1.2 → 0.2.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/CHANGELOG.md +167 -16
- package/README.md +232 -149
- package/ROADMAP.md +77 -27
- package/SECURITY.md +7 -3
- 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 +396 -227
- package/dist/types.d.ts +142 -19
- package/dist/validate.d.ts +7 -1
- package/dist/validate.js +143 -13
- package/dist/workspaces.d.ts +15 -2
- package/dist/workspaces.js +172 -88
- package/docs/benchmark.md +3 -0
- package/docs/compatibility.md +44 -3
- package/docs/examples.md +2 -1
- package/docs/execution-control.md +160 -0
- package/docs/releasing.md +66 -1
- package/docs/repository-settings.md +41 -2
- package/docs/resource-limits.md +15 -5
- package/examples/code-review.ts +3 -3
- package/examples/execution-control.ts +38 -0
- package/examples/failure-handling.ts +2 -2
- package/package.json +10 -4
package/dist/types.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export interface ExecuteNode {
|
|
|
3
3
|
id: string;
|
|
4
4
|
prompt: string;
|
|
5
5
|
model?: string;
|
|
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. */
|
|
13
|
+
workspace?: "read-only" | "worktree";
|
|
6
14
|
}
|
|
7
15
|
export interface DecisionNode {
|
|
8
16
|
type: "decision";
|
|
@@ -10,18 +18,52 @@ export interface DecisionNode {
|
|
|
10
18
|
prompt: string;
|
|
11
19
|
choices: readonly string[];
|
|
12
20
|
model?: string;
|
|
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. */
|
|
28
|
+
workspace?: "read-only" | "worktree";
|
|
13
29
|
}
|
|
14
30
|
export interface MergeNode {
|
|
15
31
|
type: "merge";
|
|
16
32
|
id: string;
|
|
17
|
-
/** Defaults to
|
|
33
|
+
/** Defaults to combining selected predecessor checkpoints in a new worktree. */
|
|
18
34
|
prompt?: string;
|
|
19
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;
|
|
20
42
|
}
|
|
21
|
-
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
|
+
});
|
|
22
63
|
export interface NodeWorkspace {
|
|
23
64
|
nodeId: string;
|
|
24
|
-
|
|
65
|
+
executionId?: string;
|
|
66
|
+
mode: "read-only" | "worktree" | "integrate";
|
|
25
67
|
workingDirectory: string;
|
|
26
68
|
worktreeRoot?: string;
|
|
27
69
|
sourceRoot?: string;
|
|
@@ -31,11 +73,13 @@ export interface NodeWorkspace {
|
|
|
31
73
|
checkpointCommit?: string;
|
|
32
74
|
/** Source checkout snapshot captured before a merge agent receives write access. */
|
|
33
75
|
backupRef?: string;
|
|
76
|
+
/** Agent decisions for this merge/integrate invocation; sources remain reusable. */
|
|
77
|
+
dispositions?: MergeDisposition[];
|
|
34
78
|
state: "preparing" | "ready" | "integrated" | "discarded" | "archived" | "failed";
|
|
35
79
|
reason?: string;
|
|
36
80
|
}
|
|
37
81
|
export interface MergeDisposition {
|
|
38
|
-
|
|
82
|
+
executionId: string;
|
|
39
83
|
disposition: "integrated" | "discarded" | "archived";
|
|
40
84
|
reason: string;
|
|
41
85
|
}
|
|
@@ -44,7 +88,7 @@ export interface GitPreview {
|
|
|
44
88
|
truncated: boolean;
|
|
45
89
|
}
|
|
46
90
|
export interface MergeSource extends NodeWorkspace {
|
|
47
|
-
/** Inspection-only summaries relative to
|
|
91
|
+
/** Inspection-only summaries relative to the job’s initial snapshot; omitted by custom runners. */
|
|
48
92
|
changes?: {
|
|
49
93
|
files: string[];
|
|
50
94
|
filesTruncated: boolean;
|
|
@@ -66,11 +110,23 @@ export interface Edge {
|
|
|
66
110
|
to: string;
|
|
67
111
|
/** Omit for an unconditional edge, including from a decision node. */
|
|
68
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;
|
|
69
122
|
}
|
|
70
123
|
export interface BraidInput {
|
|
71
124
|
goal: string;
|
|
72
|
-
nodes: readonly
|
|
125
|
+
nodes: readonly BraidInputNode[];
|
|
73
126
|
edges: readonly Edge[];
|
|
127
|
+
/** Named {{variable}} templates, expanded and validated before execution. */
|
|
128
|
+
promptTemplates?: Readonly<Record<string, string>>;
|
|
129
|
+
loops?: readonly LoopDefinition[];
|
|
74
130
|
}
|
|
75
131
|
export type NodeStatus = "pending" | "runnable" | "running" | "completed" | "skipped" | "failed";
|
|
76
132
|
export interface TokenUsage {
|
|
@@ -84,14 +140,19 @@ export interface NodeOutput {
|
|
|
84
140
|
}
|
|
85
141
|
export interface PredecessorOutput extends NodeOutput {
|
|
86
142
|
nodeId: string;
|
|
143
|
+
executionId?: string;
|
|
87
144
|
/** Failed predecessors remain available on unconditional edges. */
|
|
88
145
|
error?: ExecutionError;
|
|
89
146
|
workspace?: NodeWorkspace;
|
|
90
147
|
}
|
|
91
148
|
export interface ExecutionContext {
|
|
92
149
|
runId: string;
|
|
93
|
-
/** Equal to runId
|
|
150
|
+
/** Equal to runId; reserved identity for future shared-root accounting. */
|
|
94
151
|
rootRunId: string;
|
|
152
|
+
executionId?: string;
|
|
153
|
+
revision?: number;
|
|
154
|
+
loopId?: string;
|
|
155
|
+
iteration?: number;
|
|
95
156
|
}
|
|
96
157
|
export interface ModelRequest {
|
|
97
158
|
goal: string;
|
|
@@ -108,9 +169,9 @@ export interface ModelRequest {
|
|
|
108
169
|
graph?: number;
|
|
109
170
|
};
|
|
110
171
|
workspace?: NodeWorkspace;
|
|
111
|
-
/**
|
|
172
|
+
/** Rejects read-only writes; adapters must wrap mutating file tools so cleanup waits for in-flight writes. */
|
|
112
173
|
withWorkspaceWrite?: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
113
|
-
/** Local Git operations: inspection for workers, integration commands for merge nodes. */
|
|
174
|
+
/** Local Git operations: inspection for workers, integration commands for merge/integrate nodes. */
|
|
114
175
|
git?: (args: string[], input?: string) => Promise<GitResult>;
|
|
115
176
|
/** Merge nodes must account for every source before returning their final answer. */
|
|
116
177
|
merge?: {
|
|
@@ -129,10 +190,14 @@ export interface ModelResponse {
|
|
|
129
190
|
}
|
|
130
191
|
/** Each call starts a fresh conversation and exposes only the adapter’s declared capabilities. */
|
|
131
192
|
export type ModelRunner = (request: ModelRequest) => Promise<ModelResponse>;
|
|
132
|
-
/** Frozen snapshots in emission order
|
|
193
|
+
/** Frozen snapshots in emission order, including graph revisions and execution identities. */
|
|
133
194
|
export type ExecutionEvent = Readonly<{
|
|
134
195
|
sequence: number;
|
|
135
196
|
timestamp: number;
|
|
197
|
+
executionId?: string;
|
|
198
|
+
revision?: number;
|
|
199
|
+
loopId?: string;
|
|
200
|
+
iteration?: number;
|
|
136
201
|
} & ({
|
|
137
202
|
type: "graph_created";
|
|
138
203
|
nodeCount: number;
|
|
@@ -142,11 +207,23 @@ export type ExecutionEvent = Readonly<{
|
|
|
142
207
|
nodeId: string;
|
|
143
208
|
nodeType: BraidNode["type"];
|
|
144
209
|
model?: string;
|
|
145
|
-
} | {
|
|
210
|
+
} | ({
|
|
146
211
|
type: "edge_created";
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
150
227
|
} | {
|
|
151
228
|
type: "workspace_updated";
|
|
152
229
|
workspace: Readonly<NodeWorkspace>;
|
|
@@ -159,6 +236,7 @@ export type ExecutionEvent = Readonly<{
|
|
|
159
236
|
to: string;
|
|
160
237
|
output: string;
|
|
161
238
|
decision?: string;
|
|
239
|
+
fromExecutionId?: string;
|
|
162
240
|
} | {
|
|
163
241
|
type: "node_started";
|
|
164
242
|
nodeId: string;
|
|
@@ -189,7 +267,7 @@ export type ExecutionEvent = Readonly<{
|
|
|
189
267
|
export interface BraidOptions {
|
|
190
268
|
runner: ModelRunner;
|
|
191
269
|
defaultModel?: string;
|
|
192
|
-
/** Source checkout
|
|
270
|
+
/** Source checkout for the initial snapshot and explicit integrate nodes. */
|
|
193
271
|
cwd?: string;
|
|
194
272
|
/** Positive integer. Defaults to 4. */
|
|
195
273
|
maxConcurrency?: number;
|
|
@@ -201,30 +279,36 @@ export interface BraidOptions {
|
|
|
201
279
|
signal?: AbortSignal;
|
|
202
280
|
/** Live observer. Throws/rejections are ignored; returned work is not awaited. */
|
|
203
281
|
onEvent?: (event: ExecutionEvent) => void;
|
|
282
|
+
/** Total admitted executions across loops and graph updates. Default: 1000. */
|
|
283
|
+
maxExecutions?: number;
|
|
204
284
|
}
|
|
205
285
|
export interface ExecutionError {
|
|
206
|
-
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";
|
|
207
287
|
message: string;
|
|
208
288
|
}
|
|
209
289
|
/** Output is absent if no valid response was received; status determines success. */
|
|
210
290
|
export interface NodeResult extends Partial<NodeOutput> {
|
|
211
291
|
id: string;
|
|
292
|
+
executionId?: string;
|
|
212
293
|
status: NodeStatus;
|
|
213
294
|
usage?: TokenUsage;
|
|
214
295
|
startedAt?: number;
|
|
215
296
|
finishedAt?: number;
|
|
216
297
|
latencyMs?: number;
|
|
217
298
|
error?: ExecutionError;
|
|
218
|
-
skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled";
|
|
299
|
+
skipReason?: "inactive" | "upstream_failed" | "graph_timeout" | "cancelled" | "run_failed";
|
|
219
300
|
workspace?: NodeWorkspace;
|
|
220
301
|
}
|
|
221
302
|
export interface BraidResult {
|
|
222
303
|
status: "completed" | "failed";
|
|
223
|
-
/**
|
|
304
|
+
/** Latest output per node among completed, unconsumed executions; exact IDs are in terminalExecutionIds. */
|
|
224
305
|
terminalOutputs: Record<string, NodeOutput>;
|
|
225
|
-
/** Immutable execution log, including graph
|
|
306
|
+
/** Immutable execution log, including graph revisions and runtime handoffs. */
|
|
226
307
|
events: readonly ExecutionEvent[];
|
|
227
308
|
nodes: Record<string, NodeResult>;
|
|
309
|
+
executions: Record<string, NodeExecution>;
|
|
310
|
+
terminalExecutionIds: string[];
|
|
311
|
+
revision: number;
|
|
228
312
|
workspaces?: Record<string, NodeWorkspace>;
|
|
229
313
|
error?: ExecutionError;
|
|
230
314
|
metadata: ExecutionContext & {
|
|
@@ -236,3 +320,42 @@ export interface BraidResult {
|
|
|
236
320
|
usageReportedNodes: number;
|
|
237
321
|
};
|
|
238
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,31 +19,104 @@ 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"]
|
|
38
|
-
:
|
|
96
|
+
? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
|
|
97
|
+
: node.type === "execute"
|
|
98
|
+
? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"]
|
|
99
|
+
: ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter"], "Node");
|
|
39
100
|
requireValid(text(node.id), "Node id must be a non-empty string");
|
|
40
101
|
requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`);
|
|
41
|
-
|
|
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);
|
|
42
105
|
requireValid(node.model === undefined || text(node.model), `Invalid model on '${node.id}'`);
|
|
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`);
|
|
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
|
+
}
|
|
43
112
|
const common = {
|
|
44
113
|
id: node.id,
|
|
45
|
-
prompt
|
|
114
|
+
prompt,
|
|
46
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 } : {}),
|
|
119
|
+
...(workspace !== undefined ? { workspace } : {}),
|
|
47
120
|
};
|
|
48
121
|
if (node.type === "decision") {
|
|
49
122
|
requireValid(Array.isArray(node.choices) && node.choices.length > 0, `Decision '${node.id}' needs at least one choice`);
|
|
@@ -66,39 +139,94 @@ export function compileGraph(input) {
|
|
|
66
139
|
}
|
|
67
140
|
for (const edge of input.edges) {
|
|
68
141
|
requireValid(isRecord(edge), "Edge must be an object");
|
|
69
|
-
fields(edge, ["from", "to", "choice"], "Edge");
|
|
70
|
-
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}'`);
|
|
71
147
|
requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`);
|
|
72
|
-
|
|
148
|
+
requireValid(edge.choice === undefined || text(edge.choice), "Invalid edge choice");
|
|
149
|
+
if (edge.choice !== undefined && edge.executionId === undefined) {
|
|
73
150
|
const source = byId.get(edge.from);
|
|
74
151
|
requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`);
|
|
75
152
|
requireValid(text(edge.choice) && source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`);
|
|
76
153
|
}
|
|
77
|
-
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]);
|
|
78
155
|
requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`);
|
|
79
156
|
seen.add(key);
|
|
80
157
|
const snapshot = {
|
|
81
158
|
from: edge.from,
|
|
82
159
|
to: edge.to,
|
|
83
160
|
...(edge.choice !== undefined ? { choice: edge.choice } : {}),
|
|
161
|
+
...(edge.feedback !== undefined ? { feedback: edge.feedback } : {}),
|
|
162
|
+
...(edge.executionId !== undefined ? { executionId: edge.executionId } : {}),
|
|
84
163
|
};
|
|
85
164
|
edges.push(snapshot);
|
|
86
165
|
incoming.get(edge.to).push(snapshot);
|
|
87
|
-
outgoing.get(edge.from)
|
|
166
|
+
outgoing.get(edge.from)?.push(snapshot);
|
|
88
167
|
}
|
|
89
168
|
// Kahn's algorithm is iterative, including for very deep graphs.
|
|
90
169
|
const nodes = [...byId.values()];
|
|
91
|
-
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]));
|
|
92
171
|
const topologicalOrder = nodes.filter((node) => remaining.get(node.id) === 0);
|
|
93
172
|
for (let i = 0; i < topologicalOrder.length; i++) {
|
|
94
173
|
for (const edge of outgoing.get(topologicalOrder[i].id)) {
|
|
174
|
+
if (edge.feedback || edge.executionId)
|
|
175
|
+
continue;
|
|
95
176
|
const count = remaining.get(edge.to) - 1;
|
|
96
177
|
remaining.set(edge.to, count);
|
|
97
178
|
if (count === 0)
|
|
98
179
|
topologicalOrder.push(byId.get(edge.to));
|
|
99
180
|
}
|
|
100
181
|
}
|
|
101
|
-
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}'`);
|
|
102
230
|
return {
|
|
103
231
|
goal: input.goal,
|
|
104
232
|
nodes,
|
|
@@ -106,5 +234,7 @@ export function compileGraph(input) {
|
|
|
106
234
|
edges,
|
|
107
235
|
incoming,
|
|
108
236
|
outgoing,
|
|
237
|
+
loops,
|
|
238
|
+
membership,
|
|
109
239
|
};
|
|
110
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,10 +7,19 @@ 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;
|
|
13
|
+
private sourceRoot;
|
|
12
14
|
private snapshot;
|
|
13
|
-
|
|
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;
|
|
14
23
|
all(): Record<string, NodeWorkspace>;
|
|
15
24
|
pending(): string[];
|
|
16
25
|
/** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
|
|
@@ -27,3 +36,7 @@ export declare class GitWorkspaces {
|
|
|
27
36
|
complete: (success: boolean) => Promise<void>;
|
|
28
37
|
}>;
|
|
29
38
|
}
|
|
39
|
+
export declare class WorkspaceInputError extends Error {
|
|
40
|
+
}
|
|
41
|
+
export declare class WorkspaceCheckpointError extends Error {
|
|
42
|
+
}
|