pi-extensible-workflows 1.0.1 → 3.0.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.
Files changed (47) hide show
  1. package/README.md +9 -2
  2. package/dist/src/agent-execution.d.ts +13 -14
  3. package/dist/src/agent-execution.js +110 -197
  4. package/dist/src/budget.d.ts +38 -0
  5. package/dist/src/budget.js +160 -0
  6. package/dist/src/cli.d.ts +9 -0
  7. package/dist/src/cli.js +536 -6
  8. package/dist/src/doctor.d.ts +4 -4
  9. package/dist/src/doctor.js +9 -29
  10. package/dist/src/execution.d.ts +17 -0
  11. package/dist/src/execution.js +630 -0
  12. package/dist/src/herdr.d.ts +12 -0
  13. package/dist/src/herdr.js +74 -0
  14. package/dist/src/host.d.ts +62 -0
  15. package/dist/src/host.js +2696 -0
  16. package/dist/src/index.d.ts +11 -557
  17. package/dist/src/index.js +8 -3974
  18. package/dist/src/persistence.d.ts +32 -19
  19. package/dist/src/persistence.js +310 -70
  20. package/dist/src/registry.d.ts +31 -0
  21. package/dist/src/registry.js +169 -0
  22. package/dist/src/session-inspector.d.ts +1 -0
  23. package/dist/src/session-inspector.js +4 -1
  24. package/dist/src/types.d.ts +565 -0
  25. package/dist/src/types.js +27 -0
  26. package/dist/src/utils.d.ts +26 -0
  27. package/dist/src/utils.js +128 -0
  28. package/dist/src/validation.d.ts +24 -0
  29. package/dist/src/validation.js +740 -0
  30. package/dist/src/workflow-evals.js +2 -1
  31. package/package.json +4 -3
  32. package/skills/pi-extensible-workflows/SKILL.md +52 -67
  33. package/src/agent-execution.ts +84 -147
  34. package/src/budget.ts +75 -0
  35. package/src/cli.ts +427 -6
  36. package/src/doctor.ts +13 -32
  37. package/src/execution.ts +540 -0
  38. package/src/herdr.ts +73 -0
  39. package/src/host.ts +2265 -0
  40. package/src/index.ts +11 -3453
  41. package/src/persistence.ts +255 -47
  42. package/src/registry.ts +157 -0
  43. package/src/session-inspector.ts +5 -1
  44. package/src/types.ts +113 -0
  45. package/src/utils.ts +108 -0
  46. package/src/validation.ts +616 -0
  47. package/src/workflow-evals.ts +2 -1
@@ -1,4 +1,4 @@
1
- import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./index.js";
1
+ import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./types.js";
2
2
  import type { OwnershipRecord } from "./agent-execution.js";
3
3
  export interface NativeSessionReference {
4
4
  sessionId: string;
@@ -11,20 +11,6 @@ export interface EffectiveSystemPrompt {
11
11
  sha256: string;
12
12
  prompt: string;
13
13
  }
14
- export interface ConversationHead {
15
- turn: number;
16
- sessionId: string;
17
- sessionFile: string;
18
- leafId: string;
19
- systemPrompt: string;
20
- systemPromptSha256: string;
21
- toolDefinitionsSha256: string;
22
- }
23
- export interface PersistedConversation {
24
- id: string;
25
- policy: JsonValue;
26
- head: ConversationHead;
27
- }
28
14
  export interface PersistedRun extends RunRecord {
29
15
  nativeSessions: readonly NativeSessionReference[];
30
16
  }
@@ -47,6 +33,11 @@ export interface WorktreeReference {
47
33
  cwd: string;
48
34
  base: string;
49
35
  }
36
+ export interface BorrowedWorktreeBinding {
37
+ name: string;
38
+ sourceRunId: string;
39
+ owner: string;
40
+ }
50
41
  export declare function projectStorageKey(cwd: string): string;
51
42
  export declare function runsDirectory(cwd: string, sessionId: string, home?: string): string;
52
43
  export declare class SessionLease {
@@ -66,14 +57,15 @@ export declare class RunStore {
66
57
  readonly cwd: string;
67
58
  readonly sessionId: string;
68
59
  readonly runId: string;
60
+ readonly home: string;
69
61
  readonly directory: string;
70
62
  private journalWrite;
71
63
  private stateWrite;
72
64
  private worktreeWrite;
65
+ private borrowedWorktreeWrite;
73
66
  private snapshotWrite;
74
67
  private launchSnapshotWrite;
75
68
  private systemPromptWrite;
76
- private conversationWrite;
77
69
  constructor(cwd: string, sessionId: string, runId: string, home?: string);
78
70
  create(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>): Promise<void>;
79
71
  isComplete(): Promise<boolean>;
@@ -90,12 +82,11 @@ export declare class RunStore {
90
82
  systemPromptPath(): string;
91
83
  recordSystemPrompt(entry: Omit<EffectiveSystemPrompt, "sha256">): Promise<void>;
92
84
  systemPrompts(): Promise<readonly EffectiveSystemPrompt[]>;
93
- conversationPath(): string;
94
- conversation(id: string): Promise<PersistedConversation | undefined>;
95
- saveConversation(conversation: PersistedConversation): Promise<void>;
96
85
  private updateJournal;
97
86
  complete(path: string, value: JsonValue): Promise<void>;
98
87
  replay(path: string): Promise<CompletedOperation | undefined>;
88
+ replayableOperations(): Promise<readonly CompletedOperation[]>;
89
+ private replayableOperationsFrom;
99
90
  awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined>;
100
91
  awaitingCheckpoints(): Promise<readonly AwaitingCheckpoint[]>;
101
92
  requestWorkflowDecision(request: PendingWorkflowDecision): Promise<void>;
@@ -104,13 +95,35 @@ export declare class RunStore {
104
95
  answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined>;
105
96
  private expectedWorktree;
106
97
  private markerPath;
98
+ private namedWorktreeOwner;
99
+ private worktreeName;
107
100
  private structuralWorktree;
101
+ private borrowedWorktreeRecords;
102
+ borrowedWorktrees(): Promise<readonly BorrowedWorktreeBinding[]>;
103
+ private borrowedWorktree;
104
+ private sourceRun;
105
+ validateParentRun(parentRunId: string): Promise<void>;
106
+ validateRetrySource(): Promise<void>;
107
+ private ownedWorktree;
108
+ private resolveBorrowedWorktree;
109
+ private findNamedWorktree;
110
+ resolveNamedWorktree(name: string, seen?: Set<string>): Promise<{
111
+ reference: WorktreeReference;
112
+ sourceRunId: string;
113
+ owner: string;
114
+ }>;
115
+ validateBorrowedWorktrees(): Promise<void>;
116
+ validateNamedWorktrees(): Promise<void>;
117
+ ownsWorktree(owner: string): Promise<boolean>;
108
118
  private cleanupMarker;
109
119
  private cleanupOrphanWorktrees;
110
120
  validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference>;
111
121
  worktree(owner: string): Promise<WorktreeReference>;
122
+ private resolveNamedWorktreeFromParent;
123
+ private bindBorrowedWorktree;
112
124
  snapshotWorktree(owner: string): Promise<string>;
113
125
  worktrees(): Promise<readonly WorktreeReference[]>;
126
+ validNamedWorktrees(): Promise<readonly string[]>;
114
127
  changedWorktrees(): Promise<readonly WorktreeReference[]>;
115
128
  saveResult(value: JsonValue): Promise<string>;
116
129
  delete(confirmed: boolean): Promise<void>;
@@ -5,13 +5,8 @@ import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFi
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { promisify } from "node:util";
8
- import { loadLaunchSnapshot, WorkflowError } from "./index.js";
9
- function isConversationArtifact(value) {
10
- if (!value || typeof value !== "object" || Array.isArray(value))
11
- return false;
12
- const artifact = value;
13
- return artifact.version === 1 && Boolean(artifact.conversations) && typeof artifact.conversations === "object" && !Array.isArray(artifact.conversations);
14
- }
8
+ import { WorkflowError } from "./types.js";
9
+ import { loadLaunchSnapshot } from "./utils.js";
15
10
  const execute = promisify(execFile);
16
11
  const gitIdentity = {
17
12
  GIT_AUTHOR_NAME: "pi-extensible-workflows", GIT_AUTHOR_EMAIL: "pi-extensible-workflows@localhost", GIT_COMMITTER_NAME: "pi-extensible-workflows", GIT_COMMITTER_EMAIL: "pi-extensible-workflows@localhost",
@@ -215,20 +210,22 @@ export class RunStore {
215
210
  cwd;
216
211
  sessionId;
217
212
  runId;
213
+ home;
218
214
  directory;
219
215
  journalWrite = Promise.resolve();
220
216
  // ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
221
217
  stateWrite = Promise.resolve();
222
218
  worktreeWrite = Promise.resolve();
219
+ borrowedWorktreeWrite = Promise.resolve();
223
220
  snapshotWrite = Promise.resolve();
224
221
  launchSnapshotWrite = Promise.resolve();
225
222
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
226
223
  systemPromptWrite = Promise.resolve();
227
- conversationWrite = Promise.resolve();
228
224
  constructor(cwd, sessionId, runId, home = homedir()) {
229
225
  this.cwd = cwd;
230
226
  this.sessionId = sessionId;
231
227
  this.runId = runId;
228
+ this.home = home;
232
229
  this.cwd = resolve(cwd);
233
230
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
234
231
  }
@@ -244,9 +241,9 @@ export class RunStore {
244
241
  await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
245
242
  await atomicJson(join(temporary, "ownership.json"), []);
246
243
  await atomicJson(join(temporary, "worktrees.json"), []);
244
+ await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
247
245
  await atomicJson(join(temporary, "state.json"), run);
248
246
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
249
- await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
250
247
  await rename(temporary, this.directory);
251
248
  }
252
249
  catch (error) {
@@ -325,54 +322,6 @@ export class RunStore {
325
322
  return (await json(this.systemPromptPath()).catch((error) => { if (error.code === "ENOENT")
326
323
  return { version: 1, entries: [] }; throw error; })).entries;
327
324
  }
328
- conversationPath() { return join(this.directory, "conversations.json"); }
329
- async conversation(id) {
330
- await this.conversationWrite;
331
- let artifact;
332
- try {
333
- const raw = await json(this.conversationPath());
334
- if (!isConversationArtifact(raw))
335
- throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt");
336
- artifact = raw;
337
- }
338
- catch (error) {
339
- if (error.code === "ENOENT")
340
- return undefined;
341
- if (error instanceof WorkflowError)
342
- throw error;
343
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
344
- }
345
- return artifact.conversations[id];
346
- }
347
- async saveConversation(conversation) {
348
- const write = this.conversationWrite.then(async () => {
349
- const path = this.conversationPath();
350
- let artifact;
351
- try {
352
- const raw = await json(path);
353
- if (!isConversationArtifact(raw))
354
- throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt");
355
- artifact = raw;
356
- }
357
- catch (error) {
358
- if (error.code === "ENOENT")
359
- artifact = { version: 1, conversations: {} };
360
- else if (error instanceof WorkflowError)
361
- throw error;
362
- else
363
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`);
364
- }
365
- const previous = artifact.conversations[conversation.id];
366
- if (previous && previous.head.turn + 1 !== conversation.head.turn)
367
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation head is not the previous turn: ${conversation.id}`);
368
- if (!previous && conversation.head.turn !== 1)
369
- throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation must start at turn one: ${conversation.id}`);
370
- artifact.conversations[conversation.id] = structuredClone(conversation);
371
- await atomicJson(path, artifact);
372
- });
373
- this.conversationWrite = write.catch(() => undefined);
374
- await write;
375
- }
376
325
  async updateJournal(update) {
377
326
  let result;
378
327
  const write = this.journalWrite.then(async () => {
@@ -394,10 +343,34 @@ export class RunStore {
394
343
  });
395
344
  }
396
345
  async replay(path) {
346
+ const operations = await this.replayableOperations();
347
+ return operations.find((operation) => operation.path === path);
348
+ }
349
+ async replayableOperations() {
350
+ return this.replayableOperationsFrom(new Set());
351
+ }
352
+ async replayableOperationsFrom(seen) {
353
+ if (seen.has(this.runId))
354
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
355
+ const nextSeen = new Set(seen);
356
+ nextSeen.add(this.runId);
397
357
  await this.journalWrite;
398
- return (await json(join(this.directory, "journal.json"))).completed[path];
358
+ const loaded = await this.load();
359
+ const operations = new Map();
360
+ if (loaded.run.retry?.sourceRunId) {
361
+ const source = await this.sourceRun(loaded.run.retry.sourceRunId);
362
+ for (const operation of await source.replayableOperationsFrom(nextSeen))
363
+ operations.set(operation.path, operation);
364
+ }
365
+ const journal = await json(join(this.directory, "journal.json"));
366
+ for (const operation of Object.values(journal.completed))
367
+ operations.set(operation.path, operation);
368
+ return [...operations.values()].map((operation) => structuredClone(operation));
399
369
  }
400
370
  async awaitCheckpoint(checkpoint) {
371
+ const replayed = await this.replay(checkpoint.path);
372
+ if (replayed)
373
+ return replayed.value;
401
374
  return this.updateJournal((journal) => {
402
375
  const completed = journal.completed[checkpoint.path];
403
376
  if (completed)
@@ -447,6 +420,26 @@ export class RunStore {
447
420
  const key = createHash("sha256").update(`${this.sessionId}\0${this.runId}\0${owner}`).digest("hex").slice(0, 16);
448
421
  return join(this.directory, `worktree-${key}.creating`);
449
422
  }
423
+ namedWorktreeOwner(name) {
424
+ if (!name.trim())
425
+ throw new WorkflowError("WORKTREE_FAILED", "Named worktree names must be non-empty");
426
+ return structuralPath("worktree", "named", name.trim());
427
+ }
428
+ worktreeName(owner) {
429
+ const prefix = `${structuralPath("worktree", "named")}/`;
430
+ if (!owner.startsWith(prefix))
431
+ return undefined;
432
+ const encoded = owner.slice(prefix.length);
433
+ if (!encoded || encoded.includes("/"))
434
+ return undefined;
435
+ try {
436
+ const name = decodeURIComponent(encoded);
437
+ return name.trim() ? name : undefined;
438
+ }
439
+ catch {
440
+ return undefined;
441
+ }
442
+ }
450
443
  structuralWorktree(owner, record) {
451
444
  if (!record || typeof record !== "object")
452
445
  throw new Error(`Invalid worktree record for ${owner}`);
@@ -458,6 +451,171 @@ export class RunStore {
458
451
  throw new Error(`Invalid worktree record for ${owner}`);
459
452
  return candidate;
460
453
  }
454
+ async borrowedWorktreeRecords(wait = true) {
455
+ if (wait)
456
+ await this.borrowedWorktreeWrite;
457
+ const records = await json(join(this.directory, "borrowed-worktrees.json")).catch((error) => { if (error.code === "ENOENT")
458
+ return []; throw error; });
459
+ if (!Array.isArray(records))
460
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings are invalid");
461
+ const seen = new Set();
462
+ return records.map((record) => {
463
+ if (!record || typeof record !== "object")
464
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
465
+ const candidate = record;
466
+ if (typeof candidate.name !== "string" || !candidate.name.trim() || candidate.name !== candidate.name.trim() || typeof candidate.sourceRunId !== "string" || !candidate.sourceRunId || typeof candidate.owner !== "string" || candidate.owner !== this.namedWorktreeOwner(candidate.name))
467
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
468
+ if (seen.has(candidate.name))
469
+ throw new WorkflowError("WORKTREE_FAILED", `Duplicate borrowed worktree binding for ${candidate.name}`);
470
+ seen.add(candidate.name);
471
+ return { name: candidate.name, sourceRunId: candidate.sourceRunId, owner: candidate.owner };
472
+ });
473
+ }
474
+ async borrowedWorktrees() { return this.borrowedWorktreeRecords(); }
475
+ async borrowedWorktree(name) {
476
+ return (await this.borrowedWorktreeRecords()).find((binding) => binding.name === name);
477
+ }
478
+ async sourceRun(sourceRunId) {
479
+ if (!sourceRunId || sourceRunId === this.runId)
480
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree source run is invalid");
481
+ const source = new RunStore(this.cwd, this.sessionId, sourceRunId, this.home);
482
+ try {
483
+ const loaded = await source.load();
484
+ if (!["completed", "failed", "stopped"].includes(loaded.run.state))
485
+ throw new Error(`Source run ${sourceRunId} is not terminal`);
486
+ return source;
487
+ }
488
+ catch (error) {
489
+ if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED")
490
+ throw error;
491
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
492
+ }
493
+ }
494
+ async validateParentRun(parentRunId) { await this.sourceRun(parentRunId); }
495
+ async validateRetrySource() {
496
+ const validate = async (current, seen) => {
497
+ if (seen.has(current.runId))
498
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
499
+ const nextSeen = new Set(seen);
500
+ nextSeen.add(current.runId);
501
+ const loaded = await current.load();
502
+ const retry = loaded.run.retry;
503
+ if (!retry)
504
+ return;
505
+ if (typeof retry.sourceRunId !== "string" || !retry.sourceRunId || retry.sourceRunId === current.runId || typeof retry.lineageRootRunId !== "string" || !retry.lineageRootRunId || !Array.isArray(retry.completedPaths) || retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(retry.incompletePaths) || retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(retry.namedWorktrees) || retry.namedWorktrees.some((name) => typeof name !== "string"))
506
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance is incomplete");
507
+ const source = await current.sourceRun(retry.sourceRunId);
508
+ const sourceRun = (await source.load()).run;
509
+ if (loaded.run.parentRunId !== retry.sourceRunId)
510
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry parent run does not match its source run");
511
+ if (sourceRun.state !== "failed")
512
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Retry source run ${retry.sourceRunId} is not failed`);
513
+ const expectedLineageRoot = sourceRun.retry?.lineageRootRunId ?? sourceRun.id;
514
+ if (retry.lineageRootRunId !== expectedLineageRoot)
515
+ throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry lineage root does not match its source run");
516
+ await validate(source, nextSeen);
517
+ };
518
+ try {
519
+ await validate(this, new Set());
520
+ }
521
+ catch (error) {
522
+ throw error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE" ? error : new WorkflowError("RESUME_INCOMPATIBLE", error instanceof Error ? error.message : String(error));
523
+ }
524
+ }
525
+ async ownedWorktree(owner, cwd) {
526
+ const records = await json(join(this.directory, "worktrees.json"));
527
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
528
+ if (matches.length !== 1)
529
+ throw new Error(`Missing or duplicate worktree record for ${owner}`);
530
+ const record = this.structuralWorktree(owner, matches[0]);
531
+ if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd))
532
+ throw new Error(`Invalid worktree record for ${owner}`);
533
+ await access(record.cwd);
534
+ return record;
535
+ }
536
+ async resolveBorrowedWorktree(binding, seen) {
537
+ try {
538
+ const source = await this.sourceRun(binding.sourceRunId);
539
+ const resolved = await source.findNamedWorktree(binding.name, seen);
540
+ if (!resolved)
541
+ throw new Error(`Missing named worktree ${binding.name} in source run ${binding.sourceRunId}`);
542
+ if (resolved.owner !== binding.owner)
543
+ throw new Error(`Borrowed worktree binding does not match source owner for ${binding.name}`);
544
+ return resolved;
545
+ }
546
+ catch (error) {
547
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
548
+ }
549
+ }
550
+ async findNamedWorktree(name, seen = new Set()) {
551
+ const owner = this.namedWorktreeOwner(name);
552
+ if (seen.has(this.runId))
553
+ throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings contain a cycle");
554
+ const nextSeen = new Set(seen);
555
+ nextSeen.add(this.runId);
556
+ const binding = await this.borrowedWorktree(name);
557
+ if (binding) {
558
+ const loaded = await this.load();
559
+ if (loaded.run.parentRunId === undefined)
560
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree ${name} has no parent run`);
561
+ const parent = await this.sourceRun(loaded.run.parentRunId);
562
+ const resolved = await parent.findNamedWorktree(name, nextSeen);
563
+ if (!resolved || resolved.sourceRunId !== binding.sourceRunId || resolved.owner !== binding.owner)
564
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${name} is not inherited from its parent run`);
565
+ return resolved;
566
+ }
567
+ const records = await json(join(this.directory, "worktrees.json"));
568
+ const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
569
+ if (matches.length === 0) {
570
+ const loaded = await this.load();
571
+ if (loaded.run.parentRunId === undefined)
572
+ return undefined;
573
+ const parent = await this.sourceRun(loaded.run.parentRunId);
574
+ return parent.findNamedWorktree(name, nextSeen);
575
+ }
576
+ try {
577
+ const reference = await this.ownedWorktree(owner);
578
+ return { reference, sourceRunId: this.runId, owner };
579
+ }
580
+ catch (error) {
581
+ throw new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
582
+ }
583
+ }
584
+ async resolveNamedWorktree(name, seen = new Set()) {
585
+ const resolved = await this.findNamedWorktree(name, seen);
586
+ if (!resolved)
587
+ throw new WorkflowError("WORKTREE_FAILED", `Missing named worktree ${name}`);
588
+ return resolved;
589
+ }
590
+ async validateBorrowedWorktrees() {
591
+ try {
592
+ const loaded = await this.load();
593
+ if (loaded.run.parentRunId !== undefined)
594
+ await this.validateParentRun(loaded.run.parentRunId);
595
+ for (const binding of await this.borrowedWorktreeRecords())
596
+ await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
597
+ }
598
+ catch (error) {
599
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
600
+ }
601
+ }
602
+ async validateNamedWorktrees() {
603
+ try {
604
+ const records = await json(join(this.directory, "worktrees.json"));
605
+ for (const record of records) {
606
+ const owner = record && typeof record === "object" && typeof record.owner === "string" ? record.owner : undefined;
607
+ if (owner && this.worktreeName(owner))
608
+ await this.validateWorktree(owner);
609
+ }
610
+ }
611
+ catch (error) {
612
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
613
+ }
614
+ }
615
+ async ownsWorktree(owner) {
616
+ const records = await json(join(this.directory, "worktrees.json"));
617
+ return records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner).length === 1;
618
+ }
461
619
  async cleanupMarker(markerPath) {
462
620
  let marker;
463
621
  try {
@@ -490,15 +648,15 @@ export class RunStore {
490
648
  async validateWorktree(owner, cwd) {
491
649
  try {
492
650
  await this.load();
493
- const records = await json(join(this.directory, "worktrees.json"));
494
- const matches = records.filter((candidate) => candidate && typeof candidate === "object" && candidate.owner === owner);
495
- if (matches.length !== 1)
496
- throw new Error(`Missing or duplicate worktree record for ${owner}`);
497
- const record = this.structuralWorktree(owner, matches[0]);
498
- if (cwd !== undefined && resolve(cwd) !== resolve(record.cwd))
499
- throw new Error(`Invalid worktree record for ${owner}`);
500
- await access(record.cwd);
501
- return record;
651
+ const name = this.worktreeName(owner);
652
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
653
+ if (binding) {
654
+ const resolved = await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
655
+ if (cwd !== undefined && resolve(cwd) !== resolve(resolved.reference.cwd))
656
+ throw new Error(`Invalid worktree record for ${owner}`);
657
+ return resolved.reference;
658
+ }
659
+ return await this.ownedWorktree(owner, cwd);
502
660
  }
503
661
  catch (error) {
504
662
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
@@ -506,10 +664,23 @@ export class RunStore {
506
664
  }
507
665
  async worktree(owner) {
508
666
  const write = this.worktreeWrite.then(async () => {
509
- await this.load();
667
+ const loaded = await this.load();
510
668
  const recordsPath = join(this.directory, "worktrees.json");
511
669
  let records = await json(recordsPath).catch((error) => { if (error.code === "ENOENT")
512
670
  return []; throw error; });
671
+ const name = this.worktreeName(owner);
672
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
673
+ if (binding)
674
+ return (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference;
675
+ if (name && loaded.run.parentRunId !== undefined) {
676
+ const resolved = await this.resolveNamedWorktreeFromParent(name, loaded.run.parentRunId);
677
+ if (resolved) {
678
+ await this.bindBorrowedWorktree({ name, sourceRunId: resolved.sourceRunId, owner: resolved.owner });
679
+ return resolved.reference;
680
+ }
681
+ }
682
+ if (name && Array.isArray(loaded.run.retry?.namedWorktrees) && loaded.run.retry.namedWorktrees.includes(name))
683
+ throw new WorkflowError("WORKTREE_FAILED", `Missing inherited named worktree ${name}`);
513
684
  const existing = records.find((record) => record.owner === owner);
514
685
  if (existing)
515
686
  return this.validateWorktree(owner);
@@ -564,6 +735,25 @@ export class RunStore {
564
735
  this.worktreeWrite = write.then(() => undefined, () => undefined);
565
736
  return write;
566
737
  }
738
+ async resolveNamedWorktreeFromParent(name, parentRunId) {
739
+ const source = await this.sourceRun(parentRunId);
740
+ return source.findNamedWorktree(name, new Set([this.runId]));
741
+ }
742
+ async bindBorrowedWorktree(binding) {
743
+ const write = this.borrowedWorktreeWrite.then(async () => {
744
+ const records = [...await this.borrowedWorktreeRecords(false)];
745
+ const existing = records.find((candidate) => candidate.name === binding.name);
746
+ if (existing) {
747
+ if (JSON.stringify(existing) !== JSON.stringify(binding))
748
+ throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${binding.name} changed`);
749
+ return;
750
+ }
751
+ records.push(binding);
752
+ await atomicJson(join(this.directory, "borrowed-worktrees.json"), records);
753
+ });
754
+ this.borrowedWorktreeWrite = write.then(() => undefined, () => undefined);
755
+ await write;
756
+ }
567
757
  async snapshotWorktree(owner) {
568
758
  try {
569
759
  const write = this.snapshotWrite.then(async () => {
@@ -593,13 +783,63 @@ export class RunStore {
593
783
  async worktrees() {
594
784
  const records = await json(join(this.directory, "worktrees.json")).catch((error) => { if (error.code === "ENOENT")
595
785
  return []; throw error; });
596
- const validated = await Promise.all(records.map(async (record) => { try {
786
+ const bindings = await this.borrowedWorktreeRecords();
787
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
788
+ const owned = await Promise.all(records.filter((record) => !boundOwners.has(record.owner)).map(async (record) => { try {
597
789
  return await this.validateWorktree(record.owner);
598
790
  }
599
791
  catch {
600
792
  return undefined;
601
793
  } }));
602
- return validated.filter((record) => record !== undefined);
794
+ const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
795
+ return [...owned.filter((record) => record !== undefined), ...borrowed];
796
+ }
797
+ async validNamedWorktrees() {
798
+ const load = async (path) => {
799
+ try {
800
+ return await json(path);
801
+ }
802
+ catch (error) {
803
+ if (error.code === "ENOENT")
804
+ return [];
805
+ throw error;
806
+ }
807
+ };
808
+ const names = new Set();
809
+ const rawRecords = await load(join(this.directory, "worktrees.json"));
810
+ let bindings;
811
+ try {
812
+ bindings = await this.borrowedWorktreeRecords();
813
+ }
814
+ catch (error) {
815
+ if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED")
816
+ return [];
817
+ throw error;
818
+ }
819
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
820
+ for (const record of Array.isArray(rawRecords) ? rawRecords : []) {
821
+ if (!record || typeof record !== "object")
822
+ continue;
823
+ const owner = record.owner;
824
+ if (typeof owner !== "string")
825
+ continue;
826
+ const name = this.worktreeName(owner);
827
+ if (!name || owner !== this.namedWorktreeOwner(name) || boundOwners.has(owner))
828
+ continue;
829
+ try {
830
+ await this.ownedWorktree(owner);
831
+ names.add(name);
832
+ }
833
+ catch { /* Do not advertise stale or invalid records. */ }
834
+ }
835
+ for (const binding of bindings) {
836
+ try {
837
+ await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
838
+ names.add(binding.name);
839
+ }
840
+ catch { /* Do not advertise stale inherited records. */ }
841
+ }
842
+ return [...names];
603
843
  }
604
844
  async changedWorktrees() {
605
845
  const changed = [];
@@ -0,0 +1,31 @@
1
+ import type { JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogVariable, WorkflowExtension, WorkflowFunction, WorkflowFunctionContext, WorkflowJournal, WorkflowVariable } from "./types.js";
2
+ export declare class WorkflowRegistry {
3
+ #private;
4
+ get frozen(): boolean;
5
+ freeze(): void;
6
+ register(extension: WorkflowExtension): void;
7
+ function(name: string): WorkflowFunction;
8
+ functions(): Readonly<Record<string, WorkflowFunction>>;
9
+ catalog(): WorkflowCatalog;
10
+ catalogIndex(): WorkflowCatalogIndex;
11
+ catalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError;
12
+ globals(): Readonly<Record<string, {
13
+ name: string;
14
+ }>>;
15
+ invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue>;
16
+ variables(): readonly {
17
+ name: string;
18
+ variable: WorkflowVariable;
19
+ }[];
20
+ agentSetupHooks(): readonly RegisteredAgentSetupHook[];
21
+ }
22
+ export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
23
+ export declare function resetWorkflowRegistry(): void;
24
+ export declare function beginWorkflowExtensionLoading(): void;
25
+ export declare function loadingRegistry(): WorkflowRegistryApi;
26
+ export declare function registerWorkflowExtension(extension: WorkflowExtension): void;
27
+ export declare function workflowCatalog(): WorkflowCatalog;
28
+ export declare function workflowCatalogIndex(): WorkflowCatalogIndex;
29
+ export declare function workflowCatalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError;
30
+ export declare function registeredWorkflowFunctions(): Readonly<Record<string, WorkflowFunction>>;
31
+ export type { WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogIndexVariable, WorkflowCatalogVariable } from "./types.js";