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.
- package/CHANGELOG.md +58 -0
- package/README.md +4 -0
- package/docs/configuration.md +11 -1
- package/docs/extension-api.md +3 -1
- package/docs/workflows.md +2 -0
- package/package.json +2 -1
- package/prompts/council.md +48 -0
- package/skills/council-mode/SKILL.md +230 -0
- package/skills/pi-subagents/SKILL.md +2 -0
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -0
- package/skills/pi-subagents/references/execution-controls.md +11 -0
- package/skills/pi-subagents/references/multi-lane-orchestration.md +39 -0
- package/src/agents/agent-management.ts +22 -3
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +29 -13
- package/src/agents/builtin-names.ts +9 -0
- package/src/agents/runtime-agent-registry.ts +418 -0
- package/src/api/agents.ts +7 -0
- package/src/api/external-job-provider.ts +3 -2
- package/src/api/preflight.ts +1 -1
- package/src/extension/config.ts +3 -0
- package/src/extension/doctor.ts +1 -0
- package/src/extension/index.ts +17 -2
- package/src/extension/rpc.ts +41 -1
- package/src/extension/schemas.ts +7 -4
- package/src/extension/tool-description.ts +2 -2
- package/src/runs/background/async-execution.ts +2 -1
- package/src/runs/background/async-job-tracker.ts +4 -3
- package/src/runs/background/async-resume.ts +2 -1
- package/src/runs/background/async-status-snapshot.ts +14 -5
- package/src/runs/background/auto-drain.ts +1 -0
- package/src/runs/background/result-watcher.ts +8 -0
- package/src/runs/background/subagent-runner.ts +7 -4
- package/src/runs/background/subagent-wait.ts +9 -5
- package/src/runs/background/terminal-run-index.ts +15 -6
- package/src/runs/background/wait-tool.ts +1 -0
- package/src/runs/foreground/execution.ts +5 -1
- package/src/runs/foreground/subagent-executor.ts +182 -46
- package/src/runs/foreground/workflow-detach-reconcile.ts +83 -15
- package/src/runs/shared/acceptance.ts +44 -1
- package/src/runs/shared/model-exclusions.ts +242 -0
- package/src/runs/shared/model-fallback.ts +55 -2
- package/src/runs/shared/subagent-control.ts +25 -3
- package/src/shared/fork-context.ts +17 -1
- package/src/shared/model-info.ts +20 -0
- package/src/shared/settings.ts +2 -2
- package/src/shared/types.ts +35 -0
- package/src/slash/slash-commands.ts +20 -6
- package/src/slash/slash-live-state.ts +3 -3
- package/src/tui/fleet-status.ts +86 -1
- package/src/tui/fleet.ts +55 -2
- package/src/tui/render.ts +73 -3
- package/src/workflows/scripted-workflow.ts +100 -12
- 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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
988
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|