@chrok/braid 0.1.3 → 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 +153 -20
- package/README.md +231 -190
- package/ROADMAP.md +77 -27
- package/SECURITY.md +4 -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 +44 -8
- 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 +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
|
}
|
|
@@ -140,7 +141,7 @@ export class GitWorkspaces {
|
|
|
140
141
|
}
|
|
141
142
|
return realpath(sourceRoot);
|
|
142
143
|
}
|
|
143
|
-
async snapshot() {
|
|
144
|
+
async snapshot(extraFiles = []) {
|
|
144
145
|
const sourceRoot = await this.sourceRoot();
|
|
145
146
|
if (!sourceRoot)
|
|
146
147
|
return undefined;
|
|
@@ -179,6 +180,21 @@ export class GitWorkspaces {
|
|
|
179
180
|
// A temporary index captures tracked edits/deletions and non-ignored new files
|
|
180
181
|
// without changing the parent's real index, branch, or working files.
|
|
181
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);
|
|
182
198
|
const tree = await git(sourceRoot, ["write-tree"], options);
|
|
183
199
|
const baseTree = baseCommit ? await git(sourceRoot, ["rev-parse", `${baseCommit}^{tree}`]) : undefined;
|
|
184
200
|
const snapshotCommit = tree === baseTree ? baseCommit : await git(sourceRoot, [
|
|
@@ -203,59 +219,78 @@ export class GitWorkspaces {
|
|
|
203
219
|
await rm(`${index}.lock`, { force: true });
|
|
204
220
|
}
|
|
205
221
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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;
|
|
223
253
|
}
|
|
224
|
-
|
|
254
|
+
throw new WorkspaceInputError("Multiple independent predecessor snapshots require an explicit merge node");
|
|
255
|
+
}
|
|
256
|
+
async prepare(request, merge = false) {
|
|
225
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);
|
|
226
261
|
if (!snapshot) {
|
|
227
262
|
const workspace = {
|
|
228
|
-
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",
|
|
229
264
|
};
|
|
230
265
|
this.report(workspace);
|
|
231
266
|
return workspace;
|
|
232
267
|
}
|
|
268
|
+
const commit = merge ? snapshot.snapshotCommit : await this.inputCommit(request, snapshot);
|
|
269
|
+
request.signal.throwIfAborted();
|
|
233
270
|
const worktreeRoot = join(snapshot.directory, crypto.randomUUID());
|
|
271
|
+
const readOnly = request.node.type !== "merge" && request.node.type !== "integrate" && request.node.workspace === "read-only";
|
|
234
272
|
const workspace = {
|
|
235
|
-
nodeId: request.node.id, mode: "worktree", state: "preparing",
|
|
273
|
+
nodeId: request.node.id, executionId, mode: readOnly ? "read-only" : "worktree", state: "preparing",
|
|
236
274
|
sourceRoot: snapshot.sourceRoot, worktreeRoot,
|
|
237
275
|
workingDirectory: resolve(worktreeRoot, snapshot.cwdSuffix),
|
|
238
|
-
snapshotCommit:
|
|
276
|
+
snapshotCommit: commit,
|
|
239
277
|
...(snapshot.baseCommit ? { baseCommit: snapshot.baseCommit } : {}),
|
|
240
278
|
};
|
|
241
|
-
this.locations.set(
|
|
279
|
+
this.locations.set(executionId, snapshot);
|
|
242
280
|
this.report(workspace);
|
|
243
281
|
try {
|
|
244
|
-
// Finish registration even if cancellation arrives during creation. Report
|
|
245
|
-
// the retained path before observing cancellation, so it can be recovered.
|
|
246
282
|
await withWorktreeLock(snapshot.commonDirectory, async () => {
|
|
247
283
|
request.signal.throwIfAborted();
|
|
248
|
-
await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot,
|
|
284
|
+
await git(snapshot.sourceRoot, ["worktree", "add", "--detach", worktreeRoot, commit], {
|
|
249
285
|
hooksDirectory: snapshot.hooksDirectory,
|
|
250
286
|
});
|
|
251
287
|
});
|
|
252
|
-
// The original cwd may be an empty or ignored directory absent from Git.
|
|
253
288
|
await mkdir(workspace.workingDirectory, { recursive: true });
|
|
254
289
|
workspace.state = "ready";
|
|
255
290
|
}
|
|
256
291
|
catch (error) {
|
|
257
292
|
workspace.state = "failed";
|
|
258
|
-
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 });
|
|
259
294
|
}
|
|
260
295
|
finally {
|
|
261
296
|
this.report(workspace);
|
|
@@ -263,19 +298,58 @@ export class GitWorkspaces {
|
|
|
263
298
|
request.signal.throwIfAborted();
|
|
264
299
|
return workspace;
|
|
265
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
|
+
}
|
|
266
340
|
all() {
|
|
267
|
-
return Object.fromEntries(
|
|
341
|
+
return structuredClone(Object.fromEntries(this.records));
|
|
268
342
|
}
|
|
269
343
|
pending() {
|
|
270
344
|
return [...this.records.values()]
|
|
271
|
-
.filter(workspace => workspace.
|
|
272
|
-
.map(workspace => workspace.nodeId);
|
|
345
|
+
.filter(workspace => workspace.worktreeRoot && ["preparing", "ready", "failed"].includes(workspace.state))
|
|
346
|
+
.map(workspace => workspace.executionId ?? workspace.nodeId);
|
|
273
347
|
}
|
|
274
348
|
/** Checkpoint every file, including ignored node outputs, before releasing a worktree. */
|
|
275
349
|
async checkpoint(workspace) {
|
|
276
350
|
if (workspace.checkpointRef)
|
|
277
351
|
return;
|
|
278
|
-
const location = this.locations.get(workspace.nodeId);
|
|
352
|
+
const location = this.locations.get(workspace.executionId ?? workspace.nodeId);
|
|
279
353
|
const cwd = workspace.worktreeRoot;
|
|
280
354
|
const options = { hooksDirectory: location.hooksDirectory };
|
|
281
355
|
// A Git tree stores only a submodule commit, never files written inside it.
|
|
@@ -294,9 +368,11 @@ export class GitWorkspaces {
|
|
|
294
368
|
}
|
|
295
369
|
await git(cwd, ["add", "--force", "--all", "--", "."], options);
|
|
296
370
|
const tree = await git(cwd, ["write-tree"], options);
|
|
297
|
-
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, [
|
|
298
374
|
"-c", "user.name=Braid", "-c", "user.email=braid@localhost", "-c", "commit.gpgsign=false",
|
|
299
|
-
"commit-tree", tree, "-p",
|
|
375
|
+
"commit-tree", tree, ...parents.flatMap(parent => ["-p", parent]), "-m", `Braid node checkpoint: ${workspace.nodeId}`,
|
|
300
376
|
], options);
|
|
301
377
|
const suffix = createHash("sha256").update(cwd).digest("hex");
|
|
302
378
|
const ref = `refs/braid/checkpoints/${suffix}`;
|
|
@@ -305,27 +381,6 @@ export class GitWorkspaces {
|
|
|
305
381
|
workspace.checkpointRef = ref;
|
|
306
382
|
this.report(workspace);
|
|
307
383
|
}
|
|
308
|
-
/** Run only after declared nodes settle, so consumers retain their source paths. */
|
|
309
|
-
async discardUnchanged() {
|
|
310
|
-
const errors = [];
|
|
311
|
-
for (const id of this.pending()) {
|
|
312
|
-
const workspace = this.records.get(id);
|
|
313
|
-
// Incomplete preparation must follow the existing failure/recovery path.
|
|
314
|
-
// A failed invocation with a successfully prepared workspace is still ready.
|
|
315
|
-
if (workspace.state !== "ready")
|
|
316
|
-
continue;
|
|
317
|
-
try {
|
|
318
|
-
await this.checkpoint(workspace);
|
|
319
|
-
if (workspace.checkpointCommit === workspace.snapshotCommit)
|
|
320
|
-
await this.release(id, "discarded", "No changes from snapshot");
|
|
321
|
-
}
|
|
322
|
-
catch (error) {
|
|
323
|
-
errors.push(error);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
if (errors.length)
|
|
327
|
-
throw new AggregateError(errors, "Some workspaces could not be checked or removed; their paths are retained in workspaces");
|
|
328
|
-
}
|
|
329
384
|
async release(id, disposition, reason) {
|
|
330
385
|
const workspace = this.records.get(id);
|
|
331
386
|
const location = this.locations.get(id);
|
|
@@ -376,7 +431,7 @@ export class GitWorkspaces {
|
|
|
376
431
|
for (const snapshot of this.allocated) {
|
|
377
432
|
// Ownership is explicit; a POSIX path prefix would miss retained Windows
|
|
378
433
|
// worktrees and recursively delete data after checkpoint/cleanup failure.
|
|
379
|
-
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 &&
|
|
380
435
|
["ready", "preparing", "failed"].includes(workspace.state));
|
|
381
436
|
if (!live) {
|
|
382
437
|
try {
|
|
@@ -398,8 +453,8 @@ export class GitWorkspaces {
|
|
|
398
453
|
throw new Error("Git input must be a string");
|
|
399
454
|
const mutate = ["add", "commit", "merge", "cherry-pick", "apply", "restore"];
|
|
400
455
|
const command = args[0];
|
|
401
|
-
if (!gitCommands(request.node.type === "merge").includes(command))
|
|
402
|
-
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");
|
|
403
458
|
const blockedOptions = ["--output", "--ext-diff", "--textconv", "--unsafe-paths", "--directory", "--strategy", "--gpg-sign", "--work-tree", "--git-dir"];
|
|
404
459
|
// Git accepts abbreviated long options (e.g. --out) and attached short
|
|
405
460
|
// values (-scustom). Apply the same boundary to those spellings.
|
|
@@ -411,8 +466,8 @@ export class GitWorkspaces {
|
|
|
411
466
|
throw new Error("Git arguments cannot override filesystem boundaries, execute external helpers, or select external strategies");
|
|
412
467
|
const workspace = request.workspace;
|
|
413
468
|
const cwd = workspace.mode === "read-only" ? workspace.workingDirectory
|
|
414
|
-
: workspace.mode === "
|
|
415
|
-
const location = this.locations.get(
|
|
469
|
+
: workspace.mode === "integrate" ? workspace.sourceRoot : workspace.worktreeRoot;
|
|
470
|
+
const location = this.locations.get(this.id(request));
|
|
416
471
|
const actualArgs = [command,
|
|
417
472
|
...(["diff", "show", "log"].includes(command) ? ["--no-ext-diff", "--no-textconv"] : []),
|
|
418
473
|
...args.slice(1),
|
|
@@ -437,51 +492,44 @@ export class GitWorkspaces {
|
|
|
437
492
|
return mutate.includes(command) ? request.withWorkspaceWrite(execute) : execute();
|
|
438
493
|
}
|
|
439
494
|
async beginMerge(request, sourceIds) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
pending = this.snapshot();
|
|
445
|
-
this.snapshots.set(request.execution.runId, pending);
|
|
446
|
-
}
|
|
447
|
-
let snapshot = await pending;
|
|
448
|
-
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) : () => { };
|
|
449
499
|
let finished = false;
|
|
450
|
-
let resolutions;
|
|
451
|
-
const ids = sourceIds.filter(id => this.
|
|
500
|
+
let resolutions = [];
|
|
501
|
+
const ids = [...new Set(sourceIds)].filter(id => this.records.get(id)?.checkpointRef);
|
|
452
502
|
try {
|
|
453
503
|
request.signal.throwIfAborted();
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
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;
|
|
457
516
|
}
|
|
458
|
-
|
|
459
|
-
await this.
|
|
460
|
-
const workspace = snapshot ? {
|
|
461
|
-
nodeId: request.node.id, mode: "merge", workingDirectory: snapshot.sourceRoot,
|
|
462
|
-
sourceRoot: snapshot.sourceRoot, snapshotCommit: snapshot.snapshotCommit, state: "ready",
|
|
463
|
-
} : { nodeId: request.node.id, mode: "read-only", workingDirectory: this.cwd, state: "ready" };
|
|
464
|
-
if (snapshot) {
|
|
465
|
-
workspace.backupRef = `refs/braid/merge-backups/${crypto.randomUUID()}`;
|
|
466
|
-
await git(snapshot.sourceRoot, ["update-ref", workspace.backupRef, snapshot.snapshotCommit], { hooksDirectory: snapshot.hooksDirectory });
|
|
517
|
+
else {
|
|
518
|
+
request.workspace = await this.prepare(request, true);
|
|
467
519
|
}
|
|
468
|
-
request.workspace
|
|
469
|
-
if (snapshot)
|
|
470
|
-
this.locations.set(request.node.id, snapshot);
|
|
471
|
-
this.report(workspace);
|
|
472
|
-
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;
|
|
473
521
|
const sources = [];
|
|
474
522
|
for (const id of ids) {
|
|
475
523
|
const source = this.records.get(id);
|
|
476
|
-
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, "--"];
|
|
477
525
|
const count = Math.max(1, ids.length);
|
|
478
526
|
const files = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--name-only", "-z", "--"], Math.floor(8_000 / count));
|
|
479
527
|
const stat = await gitPreview(source.sourceRoot, [...diffArgs.slice(0, -1), "--stat", "--"], Math.floor(4_000 / count));
|
|
480
528
|
const diff = await gitPreview(source.sourceRoot, diffArgs, Math.min(6_000, Math.floor(24_000 / count)));
|
|
481
|
-
// Truncated NUL output must not invent a partial filename.
|
|
482
529
|
const names = files.text.slice(0, files.text.lastIndexOf("\0") + 1).split("\0").filter(Boolean);
|
|
483
530
|
sources.push({ ...source, changes: { files: names, filesTruncated: files.truncated, stat, diff } });
|
|
484
531
|
}
|
|
532
|
+
const sourceStatus = integrating && target ? await gitPreview(target, ["status", "--porcelain=v1", "--untracked-files=all"], 4_000) : undefined;
|
|
485
533
|
return {
|
|
486
534
|
sources,
|
|
487
535
|
...(sourceStatus ? { sourceStatus: { ...sourceStatus, dirty: sourceStatus.text.length > 0 || sourceStatus.truncated } } : {}),
|
|
@@ -490,37 +538,30 @@ export class GitWorkspaces {
|
|
|
490
538
|
if (finished)
|
|
491
539
|
throw new Error("finish_merge must be called exactly once");
|
|
492
540
|
validateMergeDispositions(ids, decisions);
|
|
493
|
-
if (
|
|
494
|
-
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");
|
|
495
543
|
resolutions = structuredClone(decisions);
|
|
544
|
+
const workspace = this.records.get(this.id(request));
|
|
545
|
+
workspace.dispositions = structuredClone(decisions);
|
|
546
|
+
this.report(workspace);
|
|
496
547
|
finished = true;
|
|
497
548
|
},
|
|
498
549
|
complete: async (success) => {
|
|
499
550
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
// invoke more tools after finish_merge in the same model response.
|
|
503
|
-
if (success && snapshot && await git(snapshot.sourceRoot, ["ls-files", "--unmerged"])) {
|
|
504
|
-
success = false;
|
|
505
|
-
errors.push(new Error("Unresolved Git conflicts remain in the source checkout"));
|
|
506
|
-
}
|
|
507
|
-
for (const id of ids) {
|
|
508
|
-
const decision = success && finished ? resolutions.find(value => value.nodeId === id) : undefined;
|
|
509
|
-
try {
|
|
510
|
-
await this.release(id, decision?.disposition ?? "archived", decision?.reason ?? "Merge agent did not complete; changes preserved in checkpointRef");
|
|
511
|
-
}
|
|
512
|
-
catch (error) {
|
|
513
|
-
errors.push(error);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
this.snapshots.delete(request.execution.runId);
|
|
517
|
-
if (errors.length)
|
|
518
|
-
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");
|
|
519
553
|
if (success && (!finished || resolutions.some(value => value.disposition === "archived")))
|
|
520
|
-
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));
|
|
521
557
|
}
|
|
522
558
|
finally {
|
|
523
|
-
|
|
559
|
+
try {
|
|
560
|
+
await this.seal(request);
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
unlock();
|
|
564
|
+
}
|
|
524
565
|
}
|
|
525
566
|
},
|
|
526
567
|
};
|
|
@@ -531,3 +572,7 @@ export class GitWorkspaces {
|
|
|
531
572
|
}
|
|
532
573
|
}
|
|
533
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,11 +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.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
37
73
|
|
|
38
74
|
## Versioning
|
|
39
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
|