pi-subagents 0.52.0 → 0.53.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 (54) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +4 -0
  3. package/docs/configuration.md +11 -1
  4. package/docs/extension-api.md +3 -1
  5. package/docs/workflows.md +2 -0
  6. package/package.json +2 -1
  7. package/prompts/council.md +48 -0
  8. package/skills/council-mode/SKILL.md +230 -0
  9. package/skills/pi-subagents/SKILL.md +2 -0
  10. package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
  11. package/skills/pi-subagents/references/execution-controls.md +11 -0
  12. package/skills/pi-subagents/references/multi-lane-orchestration.md +39 -0
  13. package/src/agents/agent-management.ts +22 -3
  14. package/src/agents/agent-serializer.ts +2 -0
  15. package/src/agents/agents.ts +29 -13
  16. package/src/agents/builtin-names.ts +9 -0
  17. package/src/agents/runtime-agent-registry.ts +418 -0
  18. package/src/api/agents.ts +7 -0
  19. package/src/api/external-job-provider.ts +3 -2
  20. package/src/api/preflight.ts +1 -1
  21. package/src/extension/config.ts +3 -0
  22. package/src/extension/doctor.ts +1 -0
  23. package/src/extension/index.ts +17 -2
  24. package/src/extension/rpc.ts +41 -1
  25. package/src/extension/schemas.ts +7 -4
  26. package/src/extension/tool-description.ts +2 -2
  27. package/src/runs/background/async-execution.ts +2 -1
  28. package/src/runs/background/async-job-tracker.ts +4 -3
  29. package/src/runs/background/async-resume.ts +2 -1
  30. package/src/runs/background/async-status-snapshot.ts +14 -5
  31. package/src/runs/background/auto-drain.ts +1 -0
  32. package/src/runs/background/result-watcher.ts +8 -0
  33. package/src/runs/background/subagent-runner.ts +7 -4
  34. package/src/runs/background/subagent-wait.ts +9 -5
  35. package/src/runs/background/terminal-run-index.ts +15 -6
  36. package/src/runs/background/wait-tool.ts +1 -0
  37. package/src/runs/foreground/execution.ts +5 -1
  38. package/src/runs/foreground/subagent-executor.ts +182 -46
  39. package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
  40. package/src/runs/shared/acceptance.ts +44 -1
  41. package/src/runs/shared/model-exclusions.ts +242 -0
  42. package/src/runs/shared/model-fallback.ts +55 -2
  43. package/src/runs/shared/subagent-control.ts +25 -3
  44. package/src/shared/fork-context.ts +17 -1
  45. package/src/shared/model-info.ts +20 -0
  46. package/src/shared/settings.ts +2 -2
  47. package/src/shared/types.ts +35 -0
  48. package/src/slash/slash-commands.ts +20 -6
  49. package/src/slash/slash-live-state.ts +3 -3
  50. package/src/tui/fleet-status.ts +86 -1
  51. package/src/tui/fleet.ts +55 -2
  52. package/src/tui/render.ts +73 -3
  53. package/src/workflows/scripted-workflow.ts +100 -12
  54. package/src/workflows/workflow-receipt.ts +140 -0
@@ -249,7 +249,7 @@ function formatRef(result) {
249
249
  return "[" + parts.join("; ") + "]";
250
250
  }
251
251
 
252
- const runFingerprints = new Map();
252
+ let runFingerprints = new Map();
253
253
 
254
254
  function validateRunCall(key, params, label, fingerprints) {
255
255
  if (typeof key !== "string" || !runKeyPattern.test(key)) throw new Error(label + " has an invalid key.");
@@ -263,7 +263,16 @@ function validateRunCall(key, params, label, fingerprints) {
263
263
  if (params.gate !== undefined && (typeof params.gate !== "string" || !params.gate.trim())) throw new Error(label + " gate must be a non-empty command string.");
264
264
  if (params.gate !== undefined && params.acceptance !== undefined) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify.");
265
265
  if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
266
- if (params.resume !== undefined && (typeof params.resume !== "string" || !params.resume.trim())) throw new Error(label + " resume must be a non-empty retained run id.");
266
+ if (params.resume !== undefined && typeof params.resume !== "string") {
267
+ const reference = params.resume;
268
+ if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new Error(label + " resume must be a retained run id or keyed workflow receipt reference.");
269
+ const fields = Object.keys(reference);
270
+ if (fields.some((field) => field !== "workflowRunId" && field !== "key" && field !== "latest")) throw new Error(label + " keyed resume contains unsupported fields.");
271
+ if (typeof reference.workflowRunId !== "string" || !reference.workflowRunId.trim()) throw new Error(label + " keyed resume workflowRunId must be non-empty.");
272
+ if (typeof reference.key !== "string" || !runKeyPattern.test(reference.key)) throw new Error(label + " keyed resume key is invalid.");
273
+ if (reference.latest !== true) throw new Error(label + " keyed resume requires latest: true.");
274
+ }
275
+ if (typeof params.resume === "string" && !params.resume.trim()) throw new Error(label + " resume must be a non-empty retained run id.");
267
276
  if (params.resume !== undefined && params.agent !== undefined) throw new Error(label + " resume and agent are mutually exclusive.");
268
277
  if (params.resume !== undefined && (typeof params.task !== "string" || !params.task.trim())) throw new Error(label + " resume requires a non-empty task follow-up.");
269
278
  assertJsonValue(params, label + " params");
@@ -290,7 +299,7 @@ const runs = Object.freeze({
290
299
  validateRunCall(key, params, "runs.all item " + index, fingerprints);
291
300
  calls.push({ key, params });
292
301
  }
293
- for (const { key, params } of calls) runFingerprints.set(key, stableRunJson(params));
302
+ runFingerprints = fingerprints;
294
303
  const batch = { id: "batch-" + (++nextCallId), calls };
295
304
  const launched = calls.map(({ key, params }) => runHostCall(key, params, true, batch));
296
305
  return trackRunObservation(launched.map(({ key, callId }) => ({ key, operation: "run", callId })), Promise.all(launched.map(({ promise }) => promise)));
@@ -538,6 +547,11 @@ export interface WorkflowScriptChildResult {
538
547
  error?: string;
539
548
  detached?: boolean;
540
549
  structuredOutput?: unknown;
550
+ requestedContext?: "fresh" | "fork";
551
+ resolvedContext?: "fresh" | "fork" | "mixed";
552
+ outputReference?: string;
553
+ resumability?: { state: "resumable" } | { state: "not-resumable"; reason: string };
554
+ continuation?: { runIds: string[] };
541
555
  artifactPaths: string[];
542
556
  results?: unknown[];
543
557
  }
@@ -570,6 +584,17 @@ export interface WorkflowSteerResult {
570
584
  error?: string;
571
585
  }
572
586
 
587
+ export interface WorkflowReceiptResumeReference {
588
+ workflowRunId: string;
589
+ key: string;
590
+ latest: true;
591
+ }
592
+
593
+ export interface WorkflowResolvedResumeReference {
594
+ runId: string;
595
+ runIds?: string[];
596
+ }
597
+
573
598
  export interface WorkflowScriptResult {
574
599
  value: unknown;
575
600
  emits: unknown[];
@@ -596,6 +621,7 @@ export interface RunWorkflowScriptOptions {
596
621
  signal?: AbortSignal;
597
622
  admit?: (calls: Array<{ key: string; params: Record<string, unknown> }>) => void | Promise<void>;
598
623
  launch: (key: string, params: Record<string, unknown>, signal: AbortSignal, admission: { admitted: boolean }) => Promise<WorkflowScriptChildResult>;
624
+ resolveResume?: (reference: WorkflowReceiptResumeReference, signal: AbortSignal) => string | WorkflowResolvedResumeReference | Promise<string | WorkflowResolvedResumeReference>;
599
625
  status: (keyOrRunId: string, signal: AbortSignal) => Promise<WorkflowScriptChildResult>;
600
626
  steer?: (key: string, message: string, options: WorkflowSteerOptions, signal: AbortSignal) => Promise<WorkflowSteerResult>;
601
627
  state?: {
@@ -616,6 +642,16 @@ function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
616
642
  return prototype === null || prototype === Object.prototype;
617
643
  }
618
644
 
645
+ function parseWorkflowResumeReference(value: unknown): WorkflowReceiptResumeReference | undefined {
646
+ if (!isRecord(value)) return undefined;
647
+ const fields = Object.keys(value);
648
+ if (fields.some((field) => field !== "workflowRunId" && field !== "key" && field !== "latest")) throw new Error("keyed resume contains unsupported fields.");
649
+ if (typeof value.workflowRunId !== "string" || !value.workflowRunId.trim()) throw new Error("keyed resume workflowRunId must be non-empty.");
650
+ const key = validateKey(value.key, "keyed resume");
651
+ if (value.latest !== true) throw new Error("keyed resume requires latest: true.");
652
+ return { workflowRunId: value.workflowRunId.trim(), key, latest: true };
653
+ }
654
+
619
655
  function omitUndefinedWorkflowValues(value: unknown, seen = new Set<object>()): unknown {
620
656
  if (value === null || typeof value !== "object") return value;
621
657
  if (seen.has(value)) return value;
@@ -629,6 +665,18 @@ function omitUndefinedWorkflowValues(value: unknown, seen = new Set<object>()):
629
665
  return normalized;
630
666
  }
631
667
 
668
+ function omitNonJsonWorkflowResultMetadata(value: unknown): unknown {
669
+ const normalized = omitUndefinedWorkflowValues(value);
670
+ if (!isPlainJsonObject(normalized) || !Object.hasOwn(normalized, "results")) return normalized;
671
+ try {
672
+ assertWorkflowJsonValue(normalized.results, "runs.run result.results");
673
+ return normalized;
674
+ } catch {
675
+ const { results: _results, ...safeResult } = normalized;
676
+ return safeResult;
677
+ }
678
+ }
679
+
632
680
  export function assertWorkflowJsonValue(value: unknown, path = "value", seen = new Set<object>()): void {
633
681
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
634
682
  if (typeof value === "number") {
@@ -881,10 +929,21 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
881
929
  }
882
930
  if (message.type !== "call" || typeof message.callId !== "number" || typeof message.method !== "string" || !isRecord(message.args)) return;
883
931
 
884
- const respond = (promise: Promise<unknown>) => {
932
+ const respond = (promise: Promise<unknown>, responsePath?: string) => {
885
933
  void promise.then(
886
934
  (value) => {
887
- if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: true, value: omitUndefinedWorkflowValues(value) });
935
+ if (settled) return;
936
+ const normalized = responsePath ? omitNonJsonWorkflowResultMetadata(value) : omitUndefinedWorkflowValues(value);
937
+ if (!responsePath) {
938
+ worker.postMessage({ type: "response", callId: message.callId, ok: true, value: normalized });
939
+ return;
940
+ }
941
+ try {
942
+ assertWorkflowJsonValue(normalized, responsePath);
943
+ worker.postMessage({ type: "response", callId: message.callId, ok: true, value: normalized });
944
+ } catch (error) {
945
+ worker.postMessage({ type: "response", callId: message.callId, ok: false, error: `${responsePath} must contain only JSON data before it can be returned from workflowScript. Return a plain projection such as { runId, ok, output }. ${error instanceof Error ? error.message : String(error)}` });
946
+ }
888
947
  },
889
948
  (error: unknown) => {
890
949
  if (!settled) worker.postMessage({ type: "response", callId: message.callId, ok: false, error: error instanceof Error ? error.message : String(error), ...(error instanceof Error && (error as { workflowErrorKind?: unknown }).workflowErrorKind === "detached-child" ? { errorKind: "detached-child" } : {}) });
@@ -984,9 +1043,13 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
984
1043
  if (params.gate !== undefined && params.resume !== undefined) {
985
1044
  return respond(Promise.reject(new Error(`runs.run('${key}') gate is not supported with retained resume.`)));
986
1045
  }
987
- if (params.resume !== undefined && (typeof params.resume !== "string" || !params.resume.trim())) {
988
- return respond(Promise.reject(new Error(`runs.run('${key}') resume must be a non-empty retained run id.`)));
1046
+ let resumeReference: WorkflowReceiptResumeReference | undefined;
1047
+ try {
1048
+ if (params.resume !== undefined && typeof params.resume !== "string") resumeReference = parseWorkflowResumeReference(params.resume);
1049
+ } catch (error) {
1050
+ return respond(Promise.reject(new Error(`runs.run('${key}') ${error instanceof Error ? error.message : String(error)}`)));
989
1051
  }
1052
+ if (typeof params.resume === "string" && !params.resume.trim()) return respond(Promise.reject(new Error(`runs.run('${key}') resume must be a non-empty retained run id.`)));
990
1053
  if (params.resume !== undefined && params.agent !== undefined) {
991
1054
  return respond(Promise.reject(new Error(`runs.run('${key}') resume and agent are mutually exclusive.`)));
992
1055
  }
@@ -1012,7 +1075,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1012
1075
  if (callObserved) existing.observed = true;
1013
1076
  trace.push({ operation: "run", key, state: "reused", ...workflowStringMetadata(params) });
1014
1077
  traceChanged();
1015
- return respond(deliver(existing.promise));
1078
+ return respond(deliver(existing.promise), `runs.run('${key}') result`);
1016
1079
  }
1017
1080
 
1018
1081
  const startedAt = Date.now();
@@ -1033,15 +1096,40 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1033
1096
  });
1034
1097
  if (batch) batchAdmissions.set(batch.id, admission);
1035
1098
  }
1036
- const promise = admission.then(() => {
1099
+ let resolvedResumeLineage: string[] | undefined;
1100
+ const promise = admission.then(async () => {
1037
1101
  if (settled || finishing || stoppedLaunches.has(key)) {
1038
1102
  const reason = childController.signal.reason;
1039
1103
  const text = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "Workflow script aborted.";
1040
1104
  return { key, ok: false, output: text, error: text, artifactPaths: [] };
1041
1105
  }
1042
- return options.launch(key, { ...params, async: params.async ?? false }, childController.signal, { admitted: true });
1106
+ const resolvedResumeValue = resumeReference
1107
+ ? await Promise.resolve().then(() => {
1108
+ if (!options.resolveResume) throw new Error("Keyed workflow receipt resume is unavailable in this host.");
1109
+ return options.resolveResume(resumeReference, childController.signal);
1110
+ })
1111
+ : undefined;
1112
+ const resolvedResume = typeof resolvedResumeValue === "string"
1113
+ ? resolvedResumeValue
1114
+ : isRecord(resolvedResumeValue) && typeof resolvedResumeValue.runId === "string"
1115
+ ? resolvedResumeValue.runId
1116
+ : undefined;
1117
+ if (resumeReference && (typeof resolvedResume !== "string" || !resolvedResume.trim())) throw new Error("Keyed workflow receipt resume resolved without a retained run id.");
1118
+ const resolvedResumeId = resolvedResume?.trim();
1119
+ if (isRecord(resolvedResumeValue)) {
1120
+ const lineage = Array.isArray(resolvedResumeValue.runIds)
1121
+ ? resolvedResumeValue.runIds.filter((runId): runId is string => typeof runId === "string" && Boolean(runId.trim())).map((runId) => runId.trim())
1122
+ : [];
1123
+ resolvedResumeLineage = [...new Set(lineage.length ? lineage : [resolvedResumeId!])];
1124
+ if (resolvedResumeLineage.at(-1) !== resolvedResumeId) resolvedResumeLineage.push(resolvedResumeId!);
1125
+ }
1126
+ const launchParams = resolvedResumeId ? { ...params, resume: resolvedResumeId } : params;
1127
+ return options.launch(key, { ...launchParams, async: launchParams.async ?? false }, childController.signal, { admitted: true });
1043
1128
  }).then((result) => {
1044
- const normalized = !result.ok && !result.error ? { ...result, error: result.output } : result;
1129
+ let normalized = !result.ok && !result.error ? { ...result, error: result.output } : result;
1130
+ if (resolvedResumeLineage?.length && normalized.runId) {
1131
+ normalized = { ...normalized, continuation: { runIds: [...new Set([...resolvedResumeLineage, normalized.runId])] } };
1132
+ }
1045
1133
  if (stoppedLaunches.has(key)) return normalized;
1046
1134
  children.set(key, normalized);
1047
1135
  const state = normalized.ok ? "completed" : normalized.detached ? "detached" : "failed";
@@ -1061,7 +1149,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1061
1149
  childOrder.push(key);
1062
1150
  trace.push({ operation: "run", key, state: "started", ...workflowStringMetadata(params) });
1063
1151
  traceChanged();
1064
- respond(deliver(promise));
1152
+ respond(deliver(promise), `runs.run('${key}') result`);
1065
1153
  });
1066
1154
 
1067
1155
  worker.postMessage({ type: "start", script: options.script, stateEnabled: options.state !== undefined });
@@ -0,0 +1,140 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
4
+ import type { WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState } from "../shared/types.ts";
5
+ import type { WorkflowReceiptResumeReference, WorkflowScriptChildResult } from "./scripted-workflow.ts";
6
+
7
+ export type { WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState } from "../shared/types.ts";
8
+
9
+ export const WORKFLOW_RECEIPT_VERSION = 1;
10
+ export const WORKFLOW_RECEIPT_FILE = "workflow-receipt.json";
11
+
12
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+
14
+ function assertSafeRunId(value: string, label: string): string {
15
+ const normalized = value.trim();
16
+ if (!normalized || path.basename(normalized) !== normalized || normalized === "." || normalized === "..") {
17
+ throw new Error(`${label} must be an exact workflow run id, not a path or prefix.`);
18
+ }
19
+ return normalized;
20
+ }
21
+
22
+ function assertKey(value: string, label: string): string {
23
+ if (!KEY_PATTERN.test(value)) throw new Error(`${label} is invalid.`);
24
+ return value;
25
+ }
26
+
27
+ export function workflowReceiptPath(asyncDirRoot: string, workflowRunId: string): string {
28
+ return path.join(asyncDirRoot, assertSafeRunId(workflowRunId, "workflowRunId"), WORKFLOW_RECEIPT_FILE);
29
+ }
30
+
31
+ export function buildWorkflowReceipt(input: {
32
+ workflowRunId: string;
33
+ state: WorkflowReceiptState;
34
+ children: WorkflowScriptChildResult[];
35
+ createdAt?: number;
36
+ }): WorkflowReceipt {
37
+ const workflowRunId = assertSafeRunId(input.workflowRunId, "workflowRunId");
38
+ const entries: Record<string, WorkflowReceiptEntry> = Object.create(null) as Record<string, WorkflowReceiptEntry>;
39
+ for (const child of input.children) {
40
+ const key = assertKey(child.key, "workflow receipt child key");
41
+ if (entries[key]) throw new Error(`Workflow receipt has duplicate child key '${key}'.`);
42
+ const runIds = [...new Set((child.continuation?.runIds ?? (child.runId ? [child.runId] : [])).filter((runId) => typeof runId === "string" && runId.trim()).map((runId) => runId.trim()))];
43
+ const latestRunId = runIds.at(-1);
44
+ const resumability = child.resumability ?? { state: "not-resumable", reason: child.runId ? "resumability was not recorded" : "child produced no run id" };
45
+ if (resumability.state === "resumable" && !latestRunId) throw new Error(`Workflow receipt child '${key}' is resumable but has no retained run id.`);
46
+ const base = {
47
+ key,
48
+ ...(child.agent ? { agent: child.agent } : {}),
49
+ ...(child.requestedContext ? { requestedContext: child.requestedContext } : {}),
50
+ ...(child.resolvedContext ? { resolvedContext: child.resolvedContext } : {}),
51
+ ...(child.outputReference ? { outputReference: child.outputReference } : {}),
52
+ continuation: { runIds },
53
+ };
54
+ entries[key] = resumability.state === "resumable"
55
+ ? { ...base, latestRunId: latestRunId!, resumability }
56
+ : { ...base, ...(latestRunId ? { latestRunId } : {}), resumability };
57
+ }
58
+ return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries };
59
+ }
60
+
61
+ export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
62
+ const receiptPath = path.join(asyncDir, WORKFLOW_RECEIPT_FILE);
63
+ writePrivateAtomicJson(receiptPath, receipt);
64
+ return receiptPath;
65
+ }
66
+
67
+ function parseEntry(value: unknown, key: string, source: string): WorkflowReceiptEntry {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' must be an object.`);
69
+ const entry = value as Record<string, unknown>;
70
+ if (entry.key !== key) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' has a mismatched key.`);
71
+ const latestRunId = entry.latestRunId;
72
+ if (latestRunId !== undefined && (typeof latestRunId !== "string" || !latestRunId.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' latestRunId must be non-empty.`);
73
+ const continuation = entry.continuation;
74
+ if (!continuation || typeof continuation !== "object" || Array.isArray(continuation) || !Array.isArray((continuation as Record<string, unknown>).runIds)) {
75
+ throw new Error(`Invalid workflow receipt '${source}': entry '${key}' continuation is missing.`);
76
+ }
77
+ const runIds = (continuation as { runIds: unknown[] }).runIds;
78
+ if (runIds.some((runId) => typeof runId !== "string" || !runId.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' continuation contains an invalid run id.`);
79
+ if (latestRunId !== undefined && runIds.at(-1) !== latestRunId) throw new Error(`Workflow receipt '${source}' entry '${key}' is stale: latestRunId does not match its continuation lineage.`);
80
+ const resumability = entry.resumability;
81
+ if (!resumability || typeof resumability !== "object" || Array.isArray(resumability)) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumability is missing.`);
82
+ const state = (resumability as Record<string, unknown>).state;
83
+ if (state !== "resumable" && state !== "not-resumable") throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumability state is invalid.`);
84
+ const reason = (resumability as Record<string, unknown>).reason;
85
+ if (state === "resumable" && latestRunId === undefined) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumable entry has no retained run id.`);
86
+ if (state === "not-resumable" && (typeof reason !== "string" || !reason.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' non-resumable reason is missing.`);
87
+ return value as WorkflowReceiptEntry;
88
+ }
89
+
90
+ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string): WorkflowReceipt {
91
+ const receiptPath = workflowReceiptPath(asyncDirRoot, workflowRunId);
92
+ let value: unknown;
93
+ try {
94
+ value = JSON.parse(fs.readFileSync(receiptPath, "utf-8")) as unknown;
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Workflow receipt '${workflowRunId}' was not found.`);
97
+ throw new Error(`Workflow receipt '${workflowRunId}' could not be read: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
98
+ }
99
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${receiptPath}': expected an object.`);
100
+ const receipt = value as Record<string, unknown>;
101
+ if (receipt.version !== WORKFLOW_RECEIPT_VERSION) throw new Error(`Invalid workflow receipt '${receiptPath}': unsupported version.`);
102
+ if (receipt.workflowRunId !== workflowRunId) throw new Error(`Workflow receipt '${receiptPath}' is stale: workflowRunId does not match.`);
103
+ if (receipt.state !== "complete" && receipt.state !== "failed" && receipt.state !== "paused" && receipt.state !== "stopped") {
104
+ throw new Error(`Workflow receipt '${receiptPath}' is stale: workflow is not terminal.`);
105
+ }
106
+ if (typeof receipt.createdAt !== "number" || !Number.isFinite(receipt.createdAt)) throw new Error(`Invalid workflow receipt '${receiptPath}': createdAt is invalid.`);
107
+ if (!receipt.entries || typeof receipt.entries !== "object" || Array.isArray(receipt.entries)) throw new Error(`Invalid workflow receipt '${receiptPath}': entries must be an object.`);
108
+ const entries: Record<string, WorkflowReceiptEntry> = Object.create(null) as Record<string, WorkflowReceiptEntry>;
109
+ for (const [key, entry] of Object.entries(receipt.entries as Record<string, unknown>)) entries[assertKey(key, "workflow receipt key")] = parseEntry(entry, key, receiptPath);
110
+ return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries };
111
+ }
112
+
113
+ export function resolveWorkflowReceiptResumeEntry(input: {
114
+ reference: WorkflowReceiptResumeReference;
115
+ asyncDirRoot: string;
116
+ assertResumable?: (runId: string) => void;
117
+ }): WorkflowReceiptEntry & { latestRunId: string; resumability: { state: "resumable" } } {
118
+ if (input.reference.latest !== true) throw new Error("Keyed workflow receipt resume requires latest: true.");
119
+ const key = assertKey(input.reference.key, "keyed resume key");
120
+ const receipt = readWorkflowReceipt(input.asyncDirRoot, input.reference.workflowRunId.trim());
121
+ const entry = receipt.entries[key];
122
+ if (!entry) throw new Error(`Workflow receipt '${receipt.workflowRunId}' has no child key '${key}'.`);
123
+ assertResumableEntry(entry, receipt.workflowRunId, key);
124
+ input.assertResumable?.(entry.latestRunId);
125
+ return entry;
126
+ }
127
+
128
+ function assertResumableEntry(entry: WorkflowReceiptEntry, workflowRunId: string, key: string): asserts entry is WorkflowReceiptEntry & { latestRunId: string; resumability: { state: "resumable" } } {
129
+ if (entry.resumability.state !== "resumable") throw new Error(`Workflow receipt '${workflowRunId}' child '${key}' is not resumable: ${entry.resumability.reason}.`);
130
+ if (!entry.latestRunId) throw new Error(`Workflow receipt '${workflowRunId}' child '${key}' has no retained run id.`);
131
+ }
132
+
133
+ export function resolveWorkflowReceiptResume(input: {
134
+ reference: WorkflowReceiptResumeReference;
135
+ asyncDirRoot: string;
136
+ assertResumable?: (runId: string) => void;
137
+ }): string {
138
+ const entry = resolveWorkflowReceiptResumeEntry(input);
139
+ return entry.latestRunId;
140
+ }