@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/workspaces.js
CHANGED
|
@@ -70,7 +70,7 @@ async function git(cwd, args, options = {}) {
|
|
|
70
70
|
...(options.signal ? { signal: options.signal } : {}),
|
|
71
71
|
maxBuffer: 8 * 1024 * 1024,
|
|
72
72
|
});
|
|
73
|
-
return stdout.trim();
|
|
73
|
+
return args.includes("-z") ? stdout : stdout.trim();
|
|
74
74
|
}
|
|
75
75
|
/** Read-only, bounded output. A large diff cannot fail workspace preparation or flood the model. */
|
|
76
76
|
async function gitPreview(cwd, args, limit) {
|
|
@@ -91,7 +91,7 @@ async function gitPreview(cwd, args, limit) {
|
|
|
91
91
|
}
|
|
92
92
|
return { text: output.slice(0, limit), truncated: overflow || output.length > limit };
|
|
93
93
|
}
|
|
94
|
-
/**
|
|
94
|
+
/** Each execution owns a fresh worktree derived from immutable predecessor checkpoints. */
|
|
95
95
|
export class GitWorkspaces {
|
|
96
96
|
cwd;
|
|
97
97
|
onWorkspace;
|
|
@@ -99,12 +99,13 @@ export class GitWorkspaces {
|
|
|
99
99
|
records = new Map();
|
|
100
100
|
locations = new Map();
|
|
101
101
|
allocated = new Set();
|
|
102
|
+
mergeParents = new Map();
|
|
102
103
|
constructor(cwd, onWorkspace) {
|
|
103
104
|
this.cwd = cwd;
|
|
104
105
|
this.onWorkspace = onWorkspace;
|
|
105
106
|
}
|
|
106
107
|
report(workspace) {
|
|
107
|
-
this.records.set(workspace.nodeId, workspace);
|
|
108
|
+
this.records.set(workspace.executionId ?? workspace.nodeId, workspace);
|
|
108
109
|
try {
|
|
109
110
|
this.onWorkspace?.({ ...workspace });
|
|
110
111
|
}
|
|
@@ -112,7 +113,7 @@ export class GitWorkspaces {
|
|
|
112
113
|
// Observers cannot change permissions or fail node execution.
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
|
-
async
|
|
116
|
+
async sourceRoot() {
|
|
116
117
|
let ancestor = resolve(this.cwd);
|
|
117
118
|
while (true) {
|
|
118
119
|
try {
|
|
@@ -138,7 +139,12 @@ export class GitWorkspaces {
|
|
|
138
139
|
return undefined;
|
|
139
140
|
throw error;
|
|
140
141
|
}
|
|
141
|
-
|
|
142
|
+
return realpath(sourceRoot);
|
|
143
|
+
}
|
|
144
|
+
async snapshot(extraFiles = []) {
|
|
145
|
+
const sourceRoot = await this.sourceRoot();
|
|
146
|
+
if (!sourceRoot)
|
|
147
|
+
return undefined;
|
|
142
148
|
const commonDirectory = await realpath(resolve(sourceRoot, await git(sourceRoot, ["rev-parse", "--git-common-dir"])));
|
|
143
149
|
const cwdSuffix = relative(sourceRoot, await realpath(this.cwd));
|
|
144
150
|
// Git records canonical worktree paths. In particular, Windows tmpdir()
|
|
@@ -174,6 +180,21 @@ export class GitWorkspaces {
|
|
|
174
180
|
// A temporary index captures tracked edits/deletions and non-ignored new files
|
|
175
181
|
// without changing the parent's real index, branch, or working files.
|
|
176
182
|
await git(sourceRoot, ["add", "--all", "--", "."], options);
|
|
183
|
+
// Include ignored files contributed by selected sources, without capturing the
|
|
184
|
+
// caller's unrelated ignored build products or dependencies.
|
|
185
|
+
const existing = [];
|
|
186
|
+
for (const file of extraFiles) {
|
|
187
|
+
try {
|
|
188
|
+
await access(join(sourceRoot, file));
|
|
189
|
+
existing.push(file);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code !== "ENOENT")
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
for (let index = 0; index < existing.length; index += 256)
|
|
197
|
+
await git(sourceRoot, ["add", "--force", "--all", "--", ...existing.slice(index, index + 256)], options);
|
|
177
198
|
const tree = await git(sourceRoot, ["write-tree"], options);
|
|
178
199
|
const baseTree = baseCommit ? await git(sourceRoot, ["rev-parse", `${baseCommit}^{tree}`]) : undefined;
|
|
179
200
|
const snapshotCommit = tree === baseTree ? baseCommit : await git(sourceRoot, [
|
|
@@ -183,7 +204,7 @@ export class GitWorkspaces {
|
|
|
183
204
|
"-m", "Braid isolated workspace snapshot",
|
|
184
205
|
], options);
|
|
185
206
|
const snapshot = {
|
|
186
|
-
directory, commonDirectory, sourceRoot, cwdSuffix, snapshotCommit, hooksDirectory,
|
|
207
|
+
directory, commonDirectory, sourceRoot, cwdSuffix, snapshotTree: tree, snapshotCommit, hooksDirectory,
|
|
187
208
|
...(baseCommit ? { baseCommit } : {}),
|
|
188
209
|
};
|
|
189
210
|
this.allocated.add(snapshot);
|
|
@@ -198,49 +219,78 @@ export class GitWorkspaces {
|
|
|
198
219
|
await rm(`${index}.lock`, { force: true });
|
|
199
220
|
}
|
|
200
221
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
222
|
+
/** Freeze the job's root snapshot before any invocation can write to the checkout. */
|
|
223
|
+
async initialize(runId) {
|
|
224
|
+
if (!this.snapshots.has(runId))
|
|
225
|
+
this.snapshots.set(runId, this.snapshot());
|
|
226
|
+
await this.snapshots.get(runId);
|
|
227
|
+
}
|
|
228
|
+
id(request) {
|
|
229
|
+
return request.execution.executionId ?? request.node.id;
|
|
230
|
+
}
|
|
231
|
+
async inputCommit(request, snapshot) {
|
|
232
|
+
const commits = [...new Set(request.predecessors.map(value => value.workspace?.checkpointCommit).filter((value) => !!value))];
|
|
233
|
+
if (!commits.length)
|
|
234
|
+
return snapshot.snapshotCommit;
|
|
235
|
+
const trees = await Promise.all(commits.map(commit => git(snapshot.sourceRoot, ["rev-parse", `${commit}^{tree}`])));
|
|
236
|
+
if (new Set(trees).size === 1)
|
|
237
|
+
return commits[0];
|
|
238
|
+
for (const candidate of commits) {
|
|
239
|
+
let containsAll = true;
|
|
240
|
+
for (const ancestor of commits) {
|
|
241
|
+
try {
|
|
242
|
+
await git(snapshot.sourceRoot, ["merge-base", "--is-ancestor", ancestor, candidate]);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error.code !== 1)
|
|
246
|
+
throw error;
|
|
247
|
+
containsAll = false;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (containsAll)
|
|
252
|
+
return candidate;
|
|
208
253
|
}
|
|
209
|
-
|
|
254
|
+
throw new WorkspaceInputError("Multiple independent predecessor snapshots require an explicit merge node");
|
|
255
|
+
}
|
|
256
|
+
async prepare(request, merge = false) {
|
|
210
257
|
request.signal.throwIfAborted();
|
|
258
|
+
await this.initialize(request.execution.runId);
|
|
259
|
+
const snapshot = await this.snapshots.get(request.execution.runId);
|
|
260
|
+
const executionId = this.id(request);
|
|
211
261
|
if (!snapshot) {
|
|
212
262
|
const workspace = {
|
|
213
|
-
nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready",
|
|
263
|
+
nodeId: request.node.id, executionId, mode: "read-only", workingDirectory: this.cwd, state: "ready",
|
|
214
264
|
};
|
|
215
265
|
this.report(workspace);
|
|
216
266
|
return workspace;
|
|
217
267
|
}
|
|
268
|
+
const commit = merge ? snapshot.snapshotCommit : await this.inputCommit(request, snapshot);
|
|
269
|
+
request.signal.throwIfAborted();
|
|
218
270
|
const worktreeRoot = join(snapshot.directory, crypto.randomUUID());
|
|
271
|
+
const readOnly = request.node.type !== "merge" && request.node.type !== "integrate" && request.node.workspace === "read-only";
|
|
219
272
|
const workspace = {
|
|
220
|
-
nodeId: request.node.id, mode: "worktree", state: "preparing",
|
|
273
|
+
nodeId: request.node.id, executionId, mode: readOnly ? "read-only" : "worktree", state: "preparing",
|
|
221
274
|
sourceRoot: snapshot.sourceRoot, worktreeRoot,
|
|
222
275
|
workingDirectory: resolve(worktreeRoot, snapshot.cwdSuffix),
|
|
223
|
-
snapshotCommit:
|
|
276
|
+
snapshotCommit: commit,
|
|
224
277
|
...(snapshot.baseCommit ? { baseCommit: snapshot.baseCommit } : {}),
|
|
225
278
|
};
|
|
226
|
-
this.locations.set(
|
|
279
|
+
this.locations.set(executionId, snapshot);
|
|
227
280
|
this.report(workspace);
|
|
228
281
|
try {
|
|
229
|
-
// Finish registration even if cancellation arrives during creation. Report
|
|
230
|
-
// the retained path before observing cancellation, so it can be recovered.
|
|
231
282
|
await withWorktreeLock(snapshot.commonDirectory, async () => {
|
|
232
283
|
request.signal.throwIfAborted();
|
|
233
|
-
await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot,
|
|
284
|
+
await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, commit], {
|
|
234
285
|
hooksDirectory: snapshot.hooksDirectory,
|
|
235
286
|
});
|
|
236
287
|
});
|
|
237
|
-
// The original cwd may be an empty or ignored directory absent from Git.
|
|
238
288
|
await mkdir(workspace.workingDirectory, { recursive: true });
|
|
239
289
|
workspace.state = "ready";
|
|
240
290
|
}
|
|
241
291
|
catch (error) {
|
|
242
292
|
workspace.state = "failed";
|
|
243
|
-
throw new Error(`Cannot prepare isolated
|
|
293
|
+
throw new Error(`Cannot prepare isolated execution worktree at ${worktreeRoot}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
244
294
|
}
|
|
245
295
|
finally {
|
|
246
296
|
this.report(workspace);
|
|
@@ -248,19 +298,58 @@ export class GitWorkspaces {
|
|
|
248
298
|
request.signal.throwIfAborted();
|
|
249
299
|
return workspace;
|
|
250
300
|
}
|
|
301
|
+
/** Seal before releasing any downstream execution, including failures with partial writes. */
|
|
302
|
+
async seal(request) {
|
|
303
|
+
try {
|
|
304
|
+
await this.sealCheckpoint(request);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw new WorkspaceCheckpointError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async sealCheckpoint(request) {
|
|
311
|
+
const workspace = this.records.get(this.id(request));
|
|
312
|
+
if (!workspace || workspace.checkpointRef || workspace.state === "failed")
|
|
313
|
+
return;
|
|
314
|
+
if (workspace.mode === "integrate") {
|
|
315
|
+
const baseline = (await this.snapshots.get(request.execution.runId));
|
|
316
|
+
const selected = this.mergeParents.get(this.id(request)) ?? [];
|
|
317
|
+
const files = new Set();
|
|
318
|
+
for (const commit of selected) {
|
|
319
|
+
const paths = await git(baseline.sourceRoot, ["diff", "--name-only", "-z", baseline.snapshotCommit, commit, "--"]);
|
|
320
|
+
for (const path of paths.split("\0").filter(Boolean))
|
|
321
|
+
files.add(path);
|
|
322
|
+
}
|
|
323
|
+
const after = await this.snapshot([...files]);
|
|
324
|
+
if (!after)
|
|
325
|
+
throw new Error("Integration checkout disappeared");
|
|
326
|
+
const parents = [...new Set([after.snapshotCommit, ...selected])];
|
|
327
|
+
const commit = parents.length === 1 ? after.snapshotCommit : await git(after.sourceRoot, [
|
|
328
|
+
"-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
|
|
329
|
+
"commit-tree", after.snapshotTree, ...parents.flatMap(parent => ["-p", parent]), "-m", "Braid integration checkpoint",
|
|
330
|
+
], { hooksDirectory: after.hooksDirectory });
|
|
331
|
+
workspace.checkpointCommit = commit;
|
|
332
|
+
workspace.checkpointRef = `refs/braid/checkpoints/${crypto.randomUUID()}`;
|
|
333
|
+
await git(after.sourceRoot, ["update-ref", workspace.checkpointRef, commit]);
|
|
334
|
+
this.report(workspace);
|
|
335
|
+
}
|
|
336
|
+
else if (workspace.worktreeRoot) {
|
|
337
|
+
await this.checkpoint(workspace);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
251
340
|
all() {
|
|
252
|
-
return Object.fromEntries(
|
|
341
|
+
return structuredClone(Object.fromEntries(this.records));
|
|
253
342
|
}
|
|
254
343
|
pending() {
|
|
255
344
|
return [...this.records.values()]
|
|
256
|
-
.filter(workspace => workspace.
|
|
257
|
-
.map(workspace => workspace.nodeId);
|
|
345
|
+
.filter(workspace => workspace.worktreeRoot && ["preparing", "ready", "failed"].includes(workspace.state))
|
|
346
|
+
.map(workspace => workspace.executionId ?? workspace.nodeId);
|
|
258
347
|
}
|
|
259
348
|
/** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
|
|
260
349
|
async checkpoint(workspace) {
|
|
261
350
|
if (workspace.checkpointRef)
|
|
262
351
|
return;
|
|
263
|
-
const location = this.locations.get(workspace.nodeId);
|
|
352
|
+
const location = this.locations.get(workspace.executionId ?? workspace.nodeId);
|
|
264
353
|
const cwd = workspace.worktreeRoot;
|
|
265
354
|
const options = { hooksDirectory: location.hooksDirectory };
|
|
266
355
|
// A Git tree stores only a submodule commit, never files written inside it.
|
|
@@ -279,9 +368,11 @@ export class GitWorkspaces {
|
|
|
279
368
|
}
|
|
280
369
|
await git(cwd, ["add", "--force", "--all", "--", "."], options);
|
|
281
370
|
const tree = await git(cwd, ["write-tree"], options);
|
|
282
|
-
const
|
|
371
|
+
const baseTree = await git(cwd, ["rev-parse", `${workspace.snapshotCommit}^{tree}`]);
|
|
372
|
+
const parents = [...new Set([workspace.snapshotCommit, ...(this.mergeParents.get(workspace.executionId ?? workspace.nodeId) ?? [])])];
|
|
373
|
+
const commit = tree === baseTree && parents.length === 1 ? workspace.snapshotCommit : await git(cwd, [
|
|
283
374
|
"-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
|
|
284
|
-
"commit-tree", tree, "-p",
|
|
375
|
+
"commit-tree", tree, ...parents.flatMap(parent => ["-p", parent]), "-m", `Braid node checkpoint: ${workspace.nodeId}`,
|
|
285
376
|
], options);
|
|
286
377
|
const suffix = createHash("sha256").update(cwd).digest("hex");
|
|
287
378
|
const ref = `refs/braid/checkpoints/${suffix}`;
|
|
@@ -340,7 +431,7 @@ export class GitWorkspaces {
|
|
|
340
431
|
for (const snapshot of this.allocated) {
|
|
341
432
|
// Ownership is explicit; a POSIX path prefix would miss retained Windows
|
|
342
433
|
// worktrees and recursively delete data after checkpoint/cleanup failure.
|
|
343
|
-
const live = [...this.records.values()].some(workspace => workspace.
|
|
434
|
+
const live = [...this.records.values()].some(workspace => !!workspace.worktreeRoot && this.locations.get(workspace.executionId ?? workspace.nodeId) === snapshot &&
|
|
344
435
|
["ready", "preparing", "failed"].includes(workspace.state));
|
|
345
436
|
if (!live) {
|
|
346
437
|
try {
|
|
@@ -362,8 +453,8 @@ export class GitWorkspaces {
|
|
|
362
453
|
throw new Error("Git input must be a string");
|
|
363
454
|
const mutate = ["add", "commit", "merge", "cherry-pick", "apply", "restore"];
|
|
364
455
|
const command = args[0];
|
|
365
|
-
if (!gitCommands(request.node.type === "merge").includes(command))
|
|
366
|
-
throw unavailableGitCommand(command, request.node.type === "merge");
|
|
456
|
+
if (!gitCommands(request.node.type === "merge" || request.node.type === "integrate").includes(command))
|
|
457
|
+
throw unavailableGitCommand(command, request.node.type === "merge" || request.node.type === "integrate");
|
|
367
458
|
const blockedOptions = ["--output", "--ext-diff", "--textconv", "--unsafe-paths", "--directory", "--strategy", "--gpg-sign", "--work-tree", "--git-dir"];
|
|
368
459
|
// Git accepts abbreviated long options (e.g. --out) and attached short
|
|
369
460
|
// values (-scustom). Apply the same boundary to those spellings.
|
|
@@ -374,15 +465,18 @@ export class GitWorkspaces {
|
|
|
374
465
|
}))
|
|
375
466
|
throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
|
|
376
467
|
const workspace = request.workspace;
|
|
377
|
-
const cwd = workspace.mode === "
|
|
378
|
-
|
|
468
|
+
const cwd = workspace.mode === "read-only" ? workspace.workingDirectory
|
|
469
|
+
: workspace.mode === "integrate" ? workspace.sourceRoot : workspace.worktreeRoot;
|
|
470
|
+
const location = this.locations.get(this.id(request));
|
|
379
471
|
const actualArgs = [command,
|
|
380
472
|
...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
|
|
381
473
|
...args.slice(1),
|
|
382
474
|
];
|
|
383
475
|
const execute = () => new Promise((resolve, reject) => {
|
|
384
476
|
const child = execFile("git", [
|
|
385
|
-
"-c", `core.hooksPath=${location.hooksDirectory}
|
|
477
|
+
...(location ? ["-c", `core.hooksPath=${location.hooksDirectory}`] : []),
|
|
478
|
+
...(workspace.mode === "read-only" ? ["--no-optional-locks"] : []),
|
|
479
|
+
"-c", "commit.gpgsign=false",
|
|
386
480
|
"-c", "core.fsmonitor=false", "--no-pager", "-C", cwd, ...actualArgs,
|
|
387
481
|
], {
|
|
388
482
|
env: { ...gitEnvironment(), GIT_TERMINAL_PROMPT: "0", GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true", GIT_MERGE_AUTOEDIT: "no" },
|
|
@@ -398,51 +492,44 @@ export class GitWorkspaces {
|
|
|
398
492
|
return mutate.includes(command) ? request.withWorkspaceWrite(execute) : execute();
|
|
399
493
|
}
|
|
400
494
|
async beginMerge(request, sourceIds) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
pending = this.snapshot();
|
|
406
|
-
this.snapshots.set(request.execution.runId, pending);
|
|
407
|
-
}
|
|
408
|
-
let snapshot = await pending;
|
|
409
|
-
const unlock = snapshot ? await lock(snapshot.sourceRoot, request.signal) : () => { };
|
|
495
|
+
await this.initialize(request.execution.runId);
|
|
496
|
+
const baseline = await this.snapshots.get(request.execution.runId);
|
|
497
|
+
const integrating = request.node.type === "integrate";
|
|
498
|
+
const unlock = integrating && baseline ? await lock(baseline.sourceRoot, request.signal) : () => { };
|
|
410
499
|
let finished = false;
|
|
411
|
-
let resolutions;
|
|
412
|
-
const ids = sourceIds.filter(id => this.
|
|
500
|
+
let resolutions = [];
|
|
501
|
+
const ids = [...new Set(sourceIds)].filter(id => this.records.get(id)?.checkpointRef);
|
|
413
502
|
try {
|
|
414
503
|
request.signal.throwIfAborted();
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
504
|
+
if (integrating && baseline) {
|
|
505
|
+
const before = (await this.snapshot());
|
|
506
|
+
const workspace = {
|
|
507
|
+
nodeId: request.node.id, executionId: this.id(request), mode: "integrate",
|
|
508
|
+
workingDirectory: resolve(before.sourceRoot, before.cwdSuffix), sourceRoot: before.sourceRoot,
|
|
509
|
+
snapshotCommit: before.snapshotCommit, state: "ready",
|
|
510
|
+
backupRef: `refs/braid/merge-backups/${crypto.randomUUID()}`,
|
|
511
|
+
};
|
|
512
|
+
await git(before.sourceRoot, ["update-ref", workspace.backupRef, before.snapshotCommit]);
|
|
513
|
+
this.locations.set(this.id(request), before);
|
|
514
|
+
this.report(workspace);
|
|
515
|
+
request.workspace = workspace;
|
|
418
516
|
}
|
|
419
|
-
|
|
420
|
-
await this.
|
|
421
|
-
const workspace = snapshot ? {
|
|
422
|
-
nodeId: request.node.id, mode: "merge", workingDirectory: snapshot.sourceRoot,
|
|
423
|
-
sourceRoot: snapshot.sourceRoot, snapshotCommit: snapshot.snapshotCommit, state: "ready",
|
|
424
|
-
} : { nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready" };
|
|
425
|
-
if (snapshot) {
|
|
426
|
-
workspace.backupRef = `refs/braid/merge-backups/${crypto.randomUUID()}`;
|
|
427
|
-
await git(snapshot.sourceRoot, ["update-ref", workspace.backupRef, snapshot.snapshotCommit], { hooksDirectory: snapshot.hooksDirectory });
|
|
517
|
+
else {
|
|
518
|
+
request.workspace = await this.prepare(request, true);
|
|
428
519
|
}
|
|
429
|
-
request.workspace
|
|
430
|
-
if (snapshot)
|
|
431
|
-
this.locations.set(request.node.id, snapshot);
|
|
432
|
-
this.report(workspace);
|
|
433
|
-
const sourceStatus = snapshot ? await gitPreview(snapshot.sourceRoot, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
|
|
520
|
+
const target = request.workspace.worktreeRoot ?? request.workspace.sourceRoot;
|
|
434
521
|
const sources = [];
|
|
435
522
|
for (const id of ids) {
|
|
436
523
|
const source = this.records.get(id);
|
|
437
|
-
const diffArgs = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames",
|
|
524
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", baseline.snapshotCommit, source.checkpointRef, "--"];
|
|
438
525
|
const count = Math.max(1, ids.length);
|
|
439
526
|
const files = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--name-only", "-z", "--"], Math.floor(8_000 / count));
|
|
440
527
|
const stat = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--stat", "--"], Math.floor(4_000 / count));
|
|
441
528
|
const diff = await gitPreview(source.sourceRoot, diffArgs, Math.min(6_000, Math.floor(24_000 / count)));
|
|
442
|
-
// Truncated NUL output must not invent a partial filename.
|
|
443
529
|
const names = files.text.slice(0, files.text.lastIndexOf("\0") + 1).split("\0").filter(Boolean);
|
|
444
530
|
sources.push({ ...source, changes: { files: names, filesTruncated: files.truncated, stat, diff } });
|
|
445
531
|
}
|
|
532
|
+
const sourceStatus = integrating && target ? await gitPreview(target, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
|
|
446
533
|
return {
|
|
447
534
|
sources,
|
|
448
535
|
...(sourceStatus ? { sourceStatus: { ...sourceStatus, dirty: sourceStatus.text.length > 0 || sourceStatus.truncated } } : {}),
|
|
@@ -451,37 +538,30 @@ export class GitWorkspaces {
|
|
|
451
538
|
if (finished)
|
|
452
539
|
throw new Error("finish_merge must be called exactly once");
|
|
453
540
|
validateMergeDispositions(ids, decisions);
|
|
454
|
-
if (
|
|
455
|
-
throw new Error("Unresolved Git conflicts remain in the
|
|
541
|
+
if (target && await git(target, ["ls-files", "--unmerged"]))
|
|
542
|
+
throw new Error("Unresolved Git conflicts remain in the target workspace");
|
|
456
543
|
resolutions = structuredClone(decisions);
|
|
544
|
+
const workspace = this.records.get(this.id(request));
|
|
545
|
+
workspace.dispositions = structuredClone(decisions);
|
|
546
|
+
this.report(workspace);
|
|
457
547
|
finished = true;
|
|
458
548
|
},
|
|
459
549
|
complete: async (success) => {
|
|
460
550
|
try {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// invoke more tools after finish_merge in the same model response.
|
|
464
|
-
if (success && snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"])) {
|
|
465
|
-
success = false;
|
|
466
|
-
errors.push(new Error("Unresolved Git conflicts remain in the source checkout"));
|
|
467
|
-
}
|
|
468
|
-
for (const id of ids) {
|
|
469
|
-
const decision = success && finished ? resolutions.find(value => value.nodeId === id) : undefined;
|
|
470
|
-
try {
|
|
471
|
-
await this.release(id, decision?.disposition ?? "archived", decision?.reason ?? "Merge agent did not complete; changes preserved in checkpointRef");
|
|
472
|
-
}
|
|
473
|
-
catch (error) {
|
|
474
|
-
errors.push(error);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
this.snapshots.delete(request.execution.runId);
|
|
478
|
-
if (errors.length)
|
|
479
|
-
throw new AggregateError(errors, "Some merge sources could not be cleaned up; their paths are retained in workspaces");
|
|
551
|
+
if (target && await git(target, ["ls-files", "--unmerged"]))
|
|
552
|
+
throw new Error("Unresolved Git conflicts remain in the target workspace");
|
|
480
553
|
if (success && (!finished || resolutions.some(value => value.disposition === "archived")))
|
|
481
|
-
throw new Error("Merge agent did not integrate or explicitly discard every source
|
|
554
|
+
throw new Error("Merge agent did not integrate or explicitly discard every source");
|
|
555
|
+
if (success)
|
|
556
|
+
this.mergeParents.set(this.id(request), resolutions.filter(value => value.disposition === "integrated").map(value => this.records.get(value.executionId).checkpointCommit));
|
|
482
557
|
}
|
|
483
558
|
finally {
|
|
484
|
-
|
|
559
|
+
try {
|
|
560
|
+
await this.seal(request);
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
unlock();
|
|
564
|
+
}
|
|
485
565
|
}
|
|
486
566
|
},
|
|
487
567
|
};
|
|
@@ -492,3 +572,7 @@ export class GitWorkspaces {
|
|
|
492
572
|
}
|
|
493
573
|
}
|
|
494
574
|
}
|
|
575
|
+
export class WorkspaceInputError extends Error {
|
|
576
|
+
}
|
|
577
|
+
export class WorkspaceCheckpointError extends Error {
|
|
578
|
+
}
|
package/docs/benchmark.md
CHANGED
|
@@ -13,6 +13,9 @@ garbage collection is requested before each run. Deadlines are disabled.
|
|
|
13
13
|
|
|
14
14
|
## Sample run — 2026-09-27
|
|
15
15
|
|
|
16
|
+
These measurements predate 0.2 execution history and live controls; rerun before
|
|
17
|
+
using them to estimate current overhead.
|
|
18
|
+
|
|
16
19
|
Environment: Apple M5, darwin/arm64, Node v25.9.0.
|
|
17
20
|
The following subset uses concurrency 4:
|
|
18
21
|
|
package/docs/compatibility.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
| Core runtime | Node.js 22+, ESM imports, TypeScript declarations, no runtime dependencies |
|
|
6
6
|
| Pi package | Node.js 22.19+, Pi 0.87.1 is the pinned validation target |
|
|
7
7
|
| CI | Core minimum Node 22.0; both packages on Node 22.19 and 24 on Linux, macOS, Windows |
|
|
8
|
-
| OpenAI-compatible runner | Chat Completions text and function-tool calls; decisions and merge nodes require tool calling |
|
|
9
|
-
| Browsers / CommonJS | No supported browser build or CommonJS entry point in 0.
|
|
8
|
+
| OpenAI-compatible runner | Chat Completions text and function-tool calls; decisions and merge/integrate nodes require tool calling |
|
|
9
|
+
| Browsers / CommonJS | No supported browser build or CommonJS entry point in 0.2 |
|
|
10
10
|
|
|
11
11
|
The CI matrix describes configured checks; see actual workflow results for each
|
|
12
12
|
commit. Offline HTTP fixtures validate the adapter contract. They do not prove
|
|
@@ -19,7 +19,8 @@ The wildcard is a loader/distribution convention, **not a claim that every Pi
|
|
|
19
19
|
version works**. Test the whole Pi suite before updating the supported target.
|
|
20
20
|
The offline host compatibility test loads the extension into a real Pi session
|
|
21
21
|
with an in-memory model provider. It checks worker prompt/tool normalization and
|
|
22
|
-
exactly one automatic continuation
|
|
22
|
+
exactly one automatic continuation per node/job reminder delivered during
|
|
23
|
+
`agent_settled`, including retrieval of a node output while its job still runs.
|
|
23
24
|
This covers the Pi 0.86/0.87 transcript and settling changes without provider
|
|
24
25
|
credentials or network model calls. Version 0.1.0 was originally validated with
|
|
25
26
|
Pi 0.85.1; the current checkout's pinned validation target is 0.87.1.
|
|
@@ -29,6 +30,46 @@ its runtime and does not need a source checkout.
|
|
|
29
30
|
|
|
30
31
|
Git must be installed for workspace execution inside a Git checkout. Non-Git
|
|
31
32
|
text-only runs do not require Git workspace management.
|
|
33
|
+
## Migrating from 0.1 to 0.2
|
|
34
|
+
|
|
35
|
+
This release deliberately changes the execution and workspace contracts:
|
|
36
|
+
|
|
37
|
+
- Replace old source-checkout `merge` nodes with `integrate`. The new `merge`
|
|
38
|
+
combines inputs in a fresh isolated worktree.
|
|
39
|
+
- Add explicit integrate nodes where a graph previously relied on automatic
|
|
40
|
+
final integration. Runs no longer append `__braid_merge__`.
|
|
41
|
+
- Treat node IDs as definition identities. Read exact historical instances from
|
|
42
|
+
`executions[executionId]`; `nodes[nodeId]` returns the latest instance.
|
|
43
|
+
- Key workspace lookups and `finish_merge` dispositions by `executionId`.
|
|
44
|
+
Source workspaces remain reusable; target `dispositions` records selections.
|
|
45
|
+
- Read-only nodes now use isolated predecessor snapshots in Git. They receive
|
|
46
|
+
checkpoints and cleanup events. All non-Git allocations also report metadata.
|
|
47
|
+
- Set `requireSuccess: true` on nodes whose failures must fail the whole job.
|
|
48
|
+
Optional failures now retain their error and artifacts while allowing recovery.
|
|
49
|
+
- Handle repeated instance events and the new revision/loop/gate events. Use
|
|
50
|
+
`braid_status({jobId, executionId})` for precise Pi result retrieval.
|
|
51
|
+
- Set finite `maxExecutions` (default 1000) and per-loop `maxIterations`. Deadlines
|
|
52
|
+
and execution limits span live graph updates and paused gates.
|
|
53
|
+
|
|
54
|
+
Use `startBraid` for live updates or pause/resume. Existing `braid` callers can
|
|
55
|
+
still await a static graph result. See [execution control](execution-control.md)
|
|
56
|
+
for the full scheduling and update contract; there is no old-behavior mode.
|
|
57
|
+
|
|
58
|
+
Graph submissions may include `promptTemplates` and template-reference prompts.
|
|
59
|
+
`BraidInput.nodes` uses `BraidInputNode`, whose `NodePrompt` accepts a string or
|
|
60
|
+
`PromptTemplateReference`. Code inspecting unrendered input prompts must narrow
|
|
61
|
+
that union; use `satisfies BraidInput` to preserve the inferred types of a literal
|
|
62
|
+
graph. Runner-facing `BraidNode` and `ModelRequest.node` retain string prompts,
|
|
63
|
+
so existing runners need no template support. Rendering happens in core before
|
|
64
|
+
execution, including for Pi submissions. Older versions reject template inputs.
|
|
65
|
+
|
|
66
|
+
All node types accept optional `notifyOnCompletion: boolean` (default `false`).
|
|
67
|
+
Core captures this preference per execution; Pi implements parent reminders for successful and failed executions.
|
|
68
|
+
Skipped nodes do not notify. Pi's optional `braid_status` `nodeId` parameter
|
|
69
|
+
requires `jobId` and retrieves full intermediate node results. Whole-job queries and reminders remain available. Paused executions send a
|
|
70
|
+
separate gate reminder; when both preferences are enabled it supplies the single
|
|
71
|
+
completion reminder for that instance. Cancellation still sends failure reminders
|
|
72
|
+
for opted-in running instances.
|
|
32
73
|
|
|
33
74
|
## Versioning
|
|
34
75
|
|
package/docs/examples.md
CHANGED
|
@@ -8,7 +8,8 @@ Replace a runner with the documented provider adapter for live use.
|
|
|
8
8
|
| --- | --- | --- |
|
|
9
9
|
| [Design comparison](../examples/basic.ts) | `npm run demo` | Decision chooses comparison, benefits/risks run concurrently, answer synthesizes them; brief branch skips |
|
|
10
10
|
| [Code review](../examples/code-review.ts) | `npx tsx examples/code-review.ts` | Correctness and test reviews run independently; final review lists both findings |
|
|
11
|
-
| [Failure handling](../examples/failure-handling.ts) | `npx tsx examples/failure-handling.ts` | `
|
|
11
|
+
| [Failure handling](../examples/failure-handling.ts) | `npx tsx examples/failure-handling.ts` | `completed`; optional failure stays in history and join completes with local findings and explicit `remote` error context |
|
|
12
|
+
| [Loop and live update](../examples/execution-control.ts) | `npx tsx examples/execution-control.ts` | Two refinement rounds, a pause, and an atomic graph update/resume |
|
|
12
13
|
| [Custom runner](../examples/custom-runner.ts) | `npx tsx examples/custom-runner.ts` | `Received direct predecessors: route` |
|
|
13
14
|
|
|
14
15
|
These text-only examples use temporary non-Git directories. The code-review
|