@signalridge/pi-workflows 1.3.1 → 1.4.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 +10 -0
- package/README.md +40 -11
- package/package.json +3 -2
- package/skills/workflow-authoring/SKILL.md +166 -18
- package/skills/workflow-authoring/examples/named-task-graph.js +34 -0
- package/skills/workflow-authoring/references/capabilities.md +2 -1
- package/skills/workflow-authoring/references/capability-details.md +22 -4
- package/skills/workflow-authoring/references/focused-recipes.md +1 -0
- package/skills/workflow-authoring/references/lifecycle.md +3 -3
- package/skills/workflow-authoring/references/pattern-selection.md +1 -0
- package/skills/workflow-authoring/references/registry-ownership.md +1 -1
- package/skills/workflow-authoring/references/review.md +7 -5
- package/skills/workflow-authoring/references/runtime.md +4 -3
- package/skills/workflow-patterns/SKILL.md +72 -33
- package/skills/workflow-review/SKILL.md +70 -0
- package/src/capabilities.ts +28 -3
- package/src/engine.ts +384 -124
- package/src/index.ts +126 -9
- package/src/journal.ts +266 -27
- package/src/orchestrator.ts +478 -0
- package/src/runtime.ts +332 -123
- package/src/ui.ts +2 -2
package/src/journal.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
* store. `run_created` carries the raw script, its hash, and its parsed meta
|
|
6
6
|
* (the runtime re-derives call indexes from the script itself, so no task
|
|
7
7
|
* array is persisted). Each completed `agent()`/`checkpoint()` call journals a
|
|
8
|
-
* `call_result` keyed by its lexical call index; a
|
|
8
|
+
* `call_result` keyed by its lexical call index; a nested workflow journals a
|
|
9
|
+
* `workflow_result` at its parent call index; a resume rebuilds the
|
|
9
10
|
* runtime's `resumeJournal` map from these entries and replays the longest
|
|
10
|
-
* unchanged prefix.
|
|
11
|
+
* unchanged prefix. Nested `workflow()` calls are represented by one
|
|
12
|
+
* `workflow_result` boundary in the parent namespace, so child calls can be
|
|
13
|
+
* replayed without re-spawning the whole nested run.
|
|
11
14
|
*
|
|
12
15
|
* The atomic recovery machinery (run/terminal/attempt recovery with generation
|
|
13
16
|
* rotations, quarantine of malformed runs, stale-generation rejection) is
|
|
@@ -21,7 +24,15 @@
|
|
|
21
24
|
|
|
22
25
|
import { createHash } from "node:crypto";
|
|
23
26
|
import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
|
|
24
|
-
import
|
|
27
|
+
import {
|
|
28
|
+
MAX_CALLS_PER_WORKFLOW,
|
|
29
|
+
normalizeAgentRetries,
|
|
30
|
+
normalizeAgentTimeout,
|
|
31
|
+
normalizeConcurrency,
|
|
32
|
+
normalizeMaxAgents,
|
|
33
|
+
normalizeTokenBudget,
|
|
34
|
+
type WorkflowMeta,
|
|
35
|
+
} from "./runtime.js";
|
|
25
36
|
|
|
26
37
|
export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
|
|
27
38
|
export const JOURNAL_SCHEMA_VERSION = 3;
|
|
@@ -37,8 +48,8 @@ export const JOURNAL_RECOVERY_ID_PREFIX = "r3-";
|
|
|
37
48
|
export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
|
|
38
49
|
/** Maximum number of distinct atomic recoveries retained while replaying one run. */
|
|
39
50
|
export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
|
|
40
|
-
/** A script run may issue up to 1000 agent()/checkpoint() calls. */
|
|
41
|
-
export const JOURNAL_CALL_LIMIT =
|
|
51
|
+
/** A script run may issue up to 1000 agent()/workflow()/checkpoint() calls. */
|
|
52
|
+
export const JOURNAL_CALL_LIMIT = MAX_CALLS_PER_WORKFLOW;
|
|
42
53
|
/** One atomic recovery event may rotate every active call in a run. */
|
|
43
54
|
export const JOURNAL_RECOVERY_NODE_LIMIT = JOURNAL_CALL_LIMIT;
|
|
44
55
|
const RECOVERY_ID_PATTERN = /^r3-[0-9a-f]{64}$/;
|
|
@@ -77,6 +88,7 @@ const WORKFLOW_STATUS_VALUES = new Set<WorkflowStatus>([
|
|
|
77
88
|
const CALL_STATUS_VALUES = new Set<CallStatus>(["running", "completed", "failed", "stopped"]);
|
|
78
89
|
const JOURNAL_KINDS = new Set([
|
|
79
90
|
"run_created",
|
|
91
|
+
"run_args",
|
|
80
92
|
"run_revision",
|
|
81
93
|
"run_removed",
|
|
82
94
|
"workflow_transition",
|
|
@@ -86,6 +98,7 @@ const JOURNAL_KINDS = new Set([
|
|
|
86
98
|
"call_transition",
|
|
87
99
|
"call_attempt",
|
|
88
100
|
"call_result",
|
|
101
|
+
"workflow_result",
|
|
89
102
|
]);
|
|
90
103
|
|
|
91
104
|
export type WorkflowOwner = ManagedOwner;
|
|
@@ -110,6 +123,10 @@ export interface ScriptRun {
|
|
|
110
123
|
script: string;
|
|
111
124
|
scriptHash: string;
|
|
112
125
|
meta: WorkflowMeta;
|
|
126
|
+
/** Frozen invocation input reused by automatic and manual resume. */
|
|
127
|
+
args?: unknown;
|
|
128
|
+
/** Explicit on current runs; absent only for legacy schema-v3 run_created facts. */
|
|
129
|
+
frozenArgsPresent?: boolean;
|
|
113
130
|
/** Number of the latest edited-script revision. */
|
|
114
131
|
revision?: number;
|
|
115
132
|
/** Toolset marker persisted across resume (e.g. "web-research" for deep-research). */
|
|
@@ -131,10 +148,14 @@ export interface ScriptRun {
|
|
|
131
148
|
attemptTracking?: boolean;
|
|
132
149
|
/** Call index → journaled result (the resume journal source). */
|
|
133
150
|
callResults: Record<string, CallResult>;
|
|
151
|
+
/** Call index → latest accepted nested workflow result generation. */
|
|
152
|
+
workflowResultGenerations: Record<string, number>;
|
|
134
153
|
compactions: Record<string, number>;
|
|
135
154
|
startedAt: number;
|
|
136
155
|
updatedAt: number;
|
|
137
156
|
error?: string;
|
|
157
|
+
/** Bounded JSON-serializable terminal script value for restored inspection. */
|
|
158
|
+
finalResult?: unknown;
|
|
138
159
|
nonResumable?: boolean;
|
|
139
160
|
/** Terminal cleanup intent while active agents settle. */
|
|
140
161
|
terminalIntent?: "stop" | "failure";
|
|
@@ -206,6 +227,9 @@ export type JournalEvent =
|
|
|
206
227
|
script: string;
|
|
207
228
|
scriptHash: string;
|
|
208
229
|
meta: WorkflowMeta;
|
|
230
|
+
args?: unknown;
|
|
231
|
+
/** Explicit args presence marker; absent only on legacy schema-v3 facts. */
|
|
232
|
+
frozenArgsPresent?: boolean;
|
|
209
233
|
toolset?: string;
|
|
210
234
|
frozenMaxAgents?: number;
|
|
211
235
|
frozenConcurrency?: number;
|
|
@@ -216,6 +240,11 @@ export type JournalEvent =
|
|
|
216
240
|
attempts?: Record<string, number>;
|
|
217
241
|
attemptIds?: Record<string, string>;
|
|
218
242
|
})
|
|
243
|
+
| (JournalBase & {
|
|
244
|
+
/** Canonical invocation args backfilled when resuming an older schema-v3 run. */
|
|
245
|
+
kind: "run_args";
|
|
246
|
+
args: unknown;
|
|
247
|
+
})
|
|
219
248
|
| (JournalBase & {
|
|
220
249
|
kind: "run_revision";
|
|
221
250
|
revision: number;
|
|
@@ -230,6 +259,7 @@ export type JournalEvent =
|
|
|
230
259
|
kind: "workflow_transition";
|
|
231
260
|
status: WorkflowStatus;
|
|
232
261
|
error?: string;
|
|
262
|
+
finalResult?: unknown;
|
|
233
263
|
terminalIntent?: "stop" | "failure";
|
|
234
264
|
})
|
|
235
265
|
| RunRecoveryEvent
|
|
@@ -260,6 +290,20 @@ export type JournalEvent =
|
|
|
260
290
|
storeDelta?: Record<string, unknown>;
|
|
261
291
|
attemptId?: string;
|
|
262
292
|
owner?: WorkflowOwner;
|
|
293
|
+
})
|
|
294
|
+
| (JournalBase & {
|
|
295
|
+
/** A completed nested workflow result, without managed-spawn attempt identity. */
|
|
296
|
+
kind: "workflow_result";
|
|
297
|
+
nodeId: string;
|
|
298
|
+
result: CallResult;
|
|
299
|
+
/** Monotonic nested-call generation used to fence changed/resumed children. */
|
|
300
|
+
generation: number;
|
|
301
|
+
/** Logical agents represented by this nested replay boundary. */
|
|
302
|
+
agentCount?: number;
|
|
303
|
+
/** Stable hash of the nested workflow name/script/args. */
|
|
304
|
+
callHash: string;
|
|
305
|
+
/** Store writes produced by the child, merged in child call order. */
|
|
306
|
+
storeDelta?: Record<string, unknown>;
|
|
263
307
|
});
|
|
264
308
|
|
|
265
309
|
export interface JournalWriter {
|
|
@@ -302,6 +346,18 @@ function parseCallIndex(value: unknown, label: string): string {
|
|
|
302
346
|
return index;
|
|
303
347
|
}
|
|
304
348
|
|
|
349
|
+
function parsePersistedValue(raw: unknown, label: string): unknown {
|
|
350
|
+
try {
|
|
351
|
+
const cloned = structuredClone(raw);
|
|
352
|
+
const serialized = JSON.stringify(cloned);
|
|
353
|
+
if (serialized === undefined) throw new Error(`${label} is not JSON-serializable`);
|
|
354
|
+
if (serialized.length > JOURNAL_SCRIPT_LIMIT) throw new Error(`${label} exceeds the persistence limit`);
|
|
355
|
+
return cloned;
|
|
356
|
+
} catch (error: unknown) {
|
|
357
|
+
throw new Error(error instanceof Error ? error.message : `${label} is not structured-cloneable`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
305
361
|
function parseMeta(raw: unknown): WorkflowMeta {
|
|
306
362
|
if (!isRecord(raw)) throw new Error("meta is invalid");
|
|
307
363
|
const name = boundedString(raw.name, "meta.name", 512);
|
|
@@ -589,7 +645,24 @@ function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: Scrip
|
|
|
589
645
|
};
|
|
590
646
|
}
|
|
591
647
|
|
|
592
|
-
function
|
|
648
|
+
function parseFrozenArgsPresence(raw: Record<string, unknown>): boolean | undefined {
|
|
649
|
+
if (raw.frozenArgsPresent === undefined) return undefined;
|
|
650
|
+
if (typeof raw.frozenArgsPresent !== "boolean") throw new Error("frozen args presence marker must be boolean");
|
|
651
|
+
const hasArgs = Object.hasOwn(raw, "args");
|
|
652
|
+
if (raw.frozenArgsPresent && (!hasArgs || raw.args === undefined)) {
|
|
653
|
+
throw new Error("frozen args presence marker requires workflow args");
|
|
654
|
+
}
|
|
655
|
+
if (!raw.frozenArgsPresent && hasArgs) {
|
|
656
|
+
throw new Error("frozen args presence marker conflicts with workflow args");
|
|
657
|
+
}
|
|
658
|
+
return raw.frozenArgsPresent;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function parseEvent(
|
|
662
|
+
raw: unknown,
|
|
663
|
+
runs: Map<string, ScriptRun>,
|
|
664
|
+
legacyArgsBackfilled: ReadonlySet<string>,
|
|
665
|
+
): JournalEvent {
|
|
593
666
|
if (!isRecord(raw)) throw new Error("journal event is not an object");
|
|
594
667
|
if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
|
|
595
668
|
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION && raw.schemaVersion !== JOURNAL_LEGACY_SCHEMA_VERSION) {
|
|
@@ -603,6 +676,8 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
603
676
|
const script = boundedString(raw.script, "script", JOURNAL_SCRIPT_LIMIT);
|
|
604
677
|
const scriptHash = boundedString(raw.scriptHash, "scriptHash", 128);
|
|
605
678
|
const meta = parseMeta(raw.meta);
|
|
679
|
+
const frozenArgsPresent = parseFrozenArgsPresence(raw);
|
|
680
|
+
const args = raw.args === undefined ? undefined : parsePersistedValue(raw.args, "workflow args");
|
|
606
681
|
const toolset =
|
|
607
682
|
raw.toolset === undefined
|
|
608
683
|
? undefined
|
|
@@ -610,18 +685,18 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
610
685
|
? raw.toolset
|
|
611
686
|
: undefined;
|
|
612
687
|
const frozenMaxAgents =
|
|
613
|
-
typeof raw.frozenMaxAgents === "number" && Number.
|
|
614
|
-
? raw.frozenMaxAgents
|
|
688
|
+
typeof raw.frozenMaxAgents === "number" && Number.isFinite(raw.frozenMaxAgents) && raw.frozenMaxAgents >= 1
|
|
689
|
+
? normalizeMaxAgents(raw.frozenMaxAgents)
|
|
615
690
|
: undefined;
|
|
616
691
|
const frozenConcurrency =
|
|
617
|
-
typeof raw.frozenConcurrency === "number" && Number.
|
|
618
|
-
? raw.frozenConcurrency
|
|
692
|
+
typeof raw.frozenConcurrency === "number" && Number.isFinite(raw.frozenConcurrency) && raw.frozenConcurrency >= 1
|
|
693
|
+
? normalizeConcurrency(raw.frozenConcurrency)
|
|
619
694
|
: undefined;
|
|
620
695
|
const frozenAgentRetries =
|
|
621
696
|
typeof raw.frozenAgentRetries === "number" &&
|
|
622
|
-
Number.
|
|
697
|
+
Number.isFinite(raw.frozenAgentRetries) &&
|
|
623
698
|
raw.frozenAgentRetries >= 0
|
|
624
|
-
? raw.frozenAgentRetries
|
|
699
|
+
? normalizeAgentRetries(raw.frozenAgentRetries)
|
|
625
700
|
: undefined;
|
|
626
701
|
const frozenTokenBudget =
|
|
627
702
|
raw.frozenTokenBudget === null
|
|
@@ -629,7 +704,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
629
704
|
: typeof raw.frozenTokenBudget === "number" &&
|
|
630
705
|
Number.isFinite(raw.frozenTokenBudget) &&
|
|
631
706
|
raw.frozenTokenBudget >= 1
|
|
632
|
-
? raw.frozenTokenBudget
|
|
707
|
+
? normalizeTokenBudget(raw.frozenTokenBudget)
|
|
633
708
|
: undefined;
|
|
634
709
|
const frozenAgentTimeoutMs =
|
|
635
710
|
raw.frozenAgentTimeoutMs === null
|
|
@@ -637,7 +712,7 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
637
712
|
: typeof raw.frozenAgentTimeoutMs === "number" &&
|
|
638
713
|
Number.isFinite(raw.frozenAgentTimeoutMs) &&
|
|
639
714
|
raw.frozenAgentTimeoutMs >= 1
|
|
640
|
-
? raw.frozenAgentTimeoutMs
|
|
715
|
+
? normalizeAgentTimeout(raw.frozenAgentTimeoutMs)
|
|
641
716
|
: undefined;
|
|
642
717
|
const frozenExcludeTools =
|
|
643
718
|
Array.isArray(raw.frozenExcludeTools) && raw.frozenExcludeTools.every((v) => typeof v === "string")
|
|
@@ -650,6 +725,8 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
650
725
|
script,
|
|
651
726
|
scriptHash,
|
|
652
727
|
meta,
|
|
728
|
+
...(args === undefined ? {} : { args }),
|
|
729
|
+
...(frozenArgsPresent === undefined ? {} : { frozenArgsPresent }),
|
|
653
730
|
...(toolset ? { toolset } : {}),
|
|
654
731
|
...(frozenMaxAgents !== undefined ? { frozenMaxAgents } : {}),
|
|
655
732
|
...(frozenConcurrency !== undefined ? { frozenConcurrency } : {}),
|
|
@@ -665,6 +742,21 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
665
742
|
|
|
666
743
|
const run = runs.get(runId);
|
|
667
744
|
if (!run) throw new Error("journal event references an unknown run");
|
|
745
|
+
if (raw.kind === "run_args") {
|
|
746
|
+
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("run args schema version is unsupported");
|
|
747
|
+
if (!Object.hasOwn(raw, "args") || raw.args === undefined) throw new Error("run args fact is missing args");
|
|
748
|
+
if (run.schemaVersion !== JOURNAL_SCHEMA_VERSION || run.frozenArgsPresent !== undefined) {
|
|
749
|
+
throw new Error("run args fact cannot override frozen workflow args presence");
|
|
750
|
+
}
|
|
751
|
+
const args = parsePersistedValue(raw.args, "run args");
|
|
752
|
+
if (
|
|
753
|
+
run.args !== undefined &&
|
|
754
|
+
(!legacyArgsBackfilled.has(runId) || JSON.stringify(run.args) !== JSON.stringify(args))
|
|
755
|
+
) {
|
|
756
|
+
throw new Error("conflicting workflow run args");
|
|
757
|
+
}
|
|
758
|
+
return { kind: "run_args", schemaVersion: JOURNAL_SCHEMA_VERSION, runId, args, timestamp };
|
|
759
|
+
}
|
|
668
760
|
if (raw.kind === "run_revision") {
|
|
669
761
|
const revision = raw.revision;
|
|
670
762
|
if (typeof revision !== "number" || !Number.isInteger(revision) || revision <= (run.revision ?? 0))
|
|
@@ -695,12 +787,21 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
695
787
|
if (raw.terminalIntent !== undefined && raw.terminalIntent !== "stop" && raw.terminalIntent !== "failure") {
|
|
696
788
|
throw new Error("workflow terminal intent is invalid");
|
|
697
789
|
}
|
|
790
|
+
let finalResult: unknown;
|
|
791
|
+
if (raw.finalResult !== undefined) {
|
|
792
|
+
try {
|
|
793
|
+
finalResult = structuredClone(raw.finalResult);
|
|
794
|
+
} catch {
|
|
795
|
+
throw new Error("workflow finalResult is not structured-cloneable");
|
|
796
|
+
}
|
|
797
|
+
}
|
|
698
798
|
return {
|
|
699
799
|
kind: "workflow_transition",
|
|
700
800
|
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
701
801
|
runId,
|
|
702
802
|
status: raw.status as WorkflowStatus,
|
|
703
803
|
timestamp,
|
|
804
|
+
...(finalResult === undefined ? {} : { finalResult }),
|
|
704
805
|
...(raw.error === undefined ? {} : { error: boundedString(raw.error, "error", JOURNAL_ERROR_LIMIT) }),
|
|
705
806
|
...(raw.terminalIntent === undefined ? {} : { terminalIntent: raw.terminalIntent }),
|
|
706
807
|
};
|
|
@@ -714,6 +815,60 @@ function parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
|
|
|
714
815
|
}
|
|
715
816
|
|
|
716
817
|
const nodeId = parseCallIndex(raw.nodeId, "nodeId");
|
|
818
|
+
if (raw.kind === "workflow_result") {
|
|
819
|
+
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("workflow result schema version is unsupported");
|
|
820
|
+
if (raw.owner !== undefined || raw.attemptId !== undefined)
|
|
821
|
+
throw new Error("workflow result cannot carry a managed attempt");
|
|
822
|
+
const result = parseResult(raw.result, "workflow result");
|
|
823
|
+
if (result.status !== "completed") throw new Error("workflow result must be completed");
|
|
824
|
+
if (result.attemptId !== undefined) throw new Error("workflow result payload cannot carry a managed attempt");
|
|
825
|
+
const generation = raw.generation;
|
|
826
|
+
if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 1 || generation > 1_000_000) {
|
|
827
|
+
throw new Error("workflow result generation is invalid");
|
|
828
|
+
}
|
|
829
|
+
const latestGeneration = run.workflowResultGenerations[nodeId] ?? 0;
|
|
830
|
+
if (generation < latestGeneration) {
|
|
831
|
+
throw new StaleJournalGenerationError("nested workflow result generation is stale");
|
|
832
|
+
}
|
|
833
|
+
if (
|
|
834
|
+
generation === latestGeneration &&
|
|
835
|
+
run.callResults[nodeId] &&
|
|
836
|
+
JSON.stringify(run.callResults[nodeId]) !== JSON.stringify(result)
|
|
837
|
+
) {
|
|
838
|
+
throw new Error("contradictory nested workflow result");
|
|
839
|
+
}
|
|
840
|
+
const agentCount =
|
|
841
|
+
raw.agentCount === undefined
|
|
842
|
+
? undefined
|
|
843
|
+
: typeof raw.agentCount === "number" &&
|
|
844
|
+
Number.isInteger(raw.agentCount) &&
|
|
845
|
+
raw.agentCount >= 0 &&
|
|
846
|
+
raw.agentCount <= JOURNAL_CALL_LIMIT
|
|
847
|
+
? raw.agentCount
|
|
848
|
+
: (() => {
|
|
849
|
+
throw new Error("workflow result agentCount is invalid");
|
|
850
|
+
})();
|
|
851
|
+
const callHash = boundedString(raw.callHash, "workflow result callHash", 128);
|
|
852
|
+
const storeDelta =
|
|
853
|
+
raw.storeDelta === undefined
|
|
854
|
+
? undefined
|
|
855
|
+
: (() => {
|
|
856
|
+
if (!isRecord(raw.storeDelta)) throw new Error("workflow result storeDelta is invalid");
|
|
857
|
+
return raw.storeDelta as Record<string, unknown>;
|
|
858
|
+
})();
|
|
859
|
+
return {
|
|
860
|
+
kind: "workflow_result",
|
|
861
|
+
schemaVersion: JOURNAL_SCHEMA_VERSION,
|
|
862
|
+
runId,
|
|
863
|
+
nodeId,
|
|
864
|
+
result,
|
|
865
|
+
generation,
|
|
866
|
+
...(agentCount === undefined ? {} : { agentCount }),
|
|
867
|
+
callHash,
|
|
868
|
+
...(storeDelta === undefined ? {} : { storeDelta }),
|
|
869
|
+
timestamp,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
717
872
|
const owner = raw.owner === undefined ? undefined : parseOwner(raw.owner, runId, nodeId);
|
|
718
873
|
const rawAttemptId =
|
|
719
874
|
raw.attemptId === undefined ? undefined : boundedString(raw.attemptId, "attemptId", JOURNAL_ID_LIMIT);
|
|
@@ -814,6 +969,7 @@ export function replayJournal(
|
|
|
814
969
|
const runs = new Map<string, ScriptRun>();
|
|
815
970
|
const quarantined = new Set<string>();
|
|
816
971
|
const removed = new Set<string>();
|
|
972
|
+
const legacyArgsBackfilled = new Set<string>();
|
|
817
973
|
/** Recovery identities are scoped to one replayed run and bounded explicitly. */
|
|
818
974
|
const seenRecoveries = new Map<string, Map<string, string>>();
|
|
819
975
|
let reported = false;
|
|
@@ -835,7 +991,9 @@ export function replayJournal(
|
|
|
835
991
|
existing &&
|
|
836
992
|
existing.script === raw.script &&
|
|
837
993
|
existing.scriptHash === raw.scriptHash &&
|
|
838
|
-
JSON.stringify(existing.meta) === JSON.stringify(raw.meta)
|
|
994
|
+
JSON.stringify(existing.meta) === JSON.stringify(raw.meta) &&
|
|
995
|
+
existing.frozenArgsPresent === parseFrozenArgsPresence(raw) &&
|
|
996
|
+
JSON.stringify(existing.args) === JSON.stringify(raw.args)
|
|
839
997
|
)
|
|
840
998
|
continue;
|
|
841
999
|
throw new Error("conflicting duplicate workflow run_created event");
|
|
@@ -869,7 +1027,7 @@ export function replayJournal(
|
|
|
869
1027
|
}
|
|
870
1028
|
}
|
|
871
1029
|
|
|
872
|
-
const event = parseEvent(raw, runs);
|
|
1030
|
+
const event = parseEvent(raw, runs, legacyArgsBackfilled);
|
|
873
1031
|
if (quarantined.has(event.runId)) continue;
|
|
874
1032
|
if (event.kind === "run_created") {
|
|
875
1033
|
applyJournalEvent(runs, event);
|
|
@@ -895,8 +1053,10 @@ export function replayJournal(
|
|
|
895
1053
|
continue;
|
|
896
1054
|
}
|
|
897
1055
|
applyJournalEvent(runs, event);
|
|
1056
|
+
if (event.kind === "run_args") legacyArgsBackfilled.add(event.runId);
|
|
898
1057
|
if (event.kind === "run_removed") {
|
|
899
1058
|
removed.add(event.runId);
|
|
1059
|
+
legacyArgsBackfilled.delete(event.runId);
|
|
900
1060
|
}
|
|
901
1061
|
} catch (error: unknown) {
|
|
902
1062
|
if (error instanceof StaleJournalGenerationError) continue;
|
|
@@ -904,6 +1064,7 @@ export function replayJournal(
|
|
|
904
1064
|
quarantined.add(rawRunId);
|
|
905
1065
|
runs.delete(rawRunId);
|
|
906
1066
|
seenRecoveries.delete(rawRunId);
|
|
1067
|
+
legacyArgsBackfilled.delete(rawRunId);
|
|
907
1068
|
}
|
|
908
1069
|
report(error instanceof Error ? error.message : String(error));
|
|
909
1070
|
}
|
|
@@ -983,6 +1144,8 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
983
1144
|
script: event.script,
|
|
984
1145
|
scriptHash: event.scriptHash,
|
|
985
1146
|
meta: event.meta,
|
|
1147
|
+
...(event.args === undefined ? {} : { args: structuredClone(event.args) }),
|
|
1148
|
+
...(event.frozenArgsPresent === undefined ? {} : { frozenArgsPresent: event.frozenArgsPresent }),
|
|
986
1149
|
revision: 0,
|
|
987
1150
|
...(event.toolset ? { toolset: event.toolset } : {}),
|
|
988
1151
|
...(event.frozenMaxAgents !== undefined ? { frozenMaxAgents: event.frozenMaxAgents } : {}),
|
|
@@ -998,6 +1161,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
998
1161
|
attemptIds,
|
|
999
1162
|
attemptTracking: event.attemptIds !== undefined,
|
|
1000
1163
|
callResults: {},
|
|
1164
|
+
workflowResultGenerations: {},
|
|
1001
1165
|
compactions: {},
|
|
1002
1166
|
startedAt: event.timestamp,
|
|
1003
1167
|
updatedAt: event.timestamp,
|
|
@@ -1013,6 +1177,9 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1013
1177
|
}
|
|
1014
1178
|
run.updatedAt = event.timestamp;
|
|
1015
1179
|
switch (event.kind) {
|
|
1180
|
+
case "run_args":
|
|
1181
|
+
run.args = structuredClone(event.args);
|
|
1182
|
+
break;
|
|
1016
1183
|
case "run_revision":
|
|
1017
1184
|
run.revision = event.revision;
|
|
1018
1185
|
run.script = event.script;
|
|
@@ -1022,6 +1189,7 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1022
1189
|
case "workflow_transition":
|
|
1023
1190
|
run.status = event.status;
|
|
1024
1191
|
if (event.error !== undefined) run.error = event.error;
|
|
1192
|
+
if (event.finalResult !== undefined) run.finalResult = structuredClone(event.finalResult);
|
|
1025
1193
|
if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
|
|
1026
1194
|
break;
|
|
1027
1195
|
case "run_recovery":
|
|
@@ -1047,6 +1215,12 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1047
1215
|
run.callStatus[event.nodeId] = event.status;
|
|
1048
1216
|
if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
|
|
1049
1217
|
break;
|
|
1218
|
+
case "workflow_result":
|
|
1219
|
+
run.workflowResultGenerations[event.nodeId] = event.generation;
|
|
1220
|
+
run.callResults[event.nodeId] = event.result;
|
|
1221
|
+
run.callStatus[event.nodeId] = event.result.status;
|
|
1222
|
+
run.compactions[event.nodeId] = event.result.compactionCount;
|
|
1223
|
+
break;
|
|
1050
1224
|
case "call_result":
|
|
1051
1225
|
if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
|
|
1052
1226
|
throw new Error("call result references a superseded attempt");
|
|
@@ -1066,6 +1240,12 @@ function applyJournalEvent(runs: Map<string, ScriptRun>, event: JournalEvent): v
|
|
|
1066
1240
|
}
|
|
1067
1241
|
|
|
1068
1242
|
function validateAggregate(run: ScriptRun): void {
|
|
1243
|
+
if (run.frozenArgsPresent === true && run.args === undefined) {
|
|
1244
|
+
throw new Error("frozen args presence marker requires workflow args");
|
|
1245
|
+
}
|
|
1246
|
+
if (run.frozenArgsPresent === false && run.args !== undefined) {
|
|
1247
|
+
throw new Error("frozen args presence marker conflicts with workflow args");
|
|
1248
|
+
}
|
|
1069
1249
|
for (const nodeId of Object.keys(run.attemptIds)) {
|
|
1070
1250
|
if (!Number.isInteger(run.attempts[nodeId]) || run.attempts[nodeId] < 1) {
|
|
1071
1251
|
throw new Error(`missing attempt generation for ${nodeId}`);
|
|
@@ -1074,8 +1254,11 @@ function validateAggregate(run: ScriptRun): void {
|
|
|
1074
1254
|
}
|
|
1075
1255
|
if (isTerminalWorkflowStatus(run.status)) {
|
|
1076
1256
|
const statuses = Object.values(run.callStatus);
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1257
|
+
if (
|
|
1258
|
+
run.status === "completed" &&
|
|
1259
|
+
statuses.some((status) => status !== "completed" && status !== "failed" && status !== "stopped")
|
|
1260
|
+
) {
|
|
1261
|
+
throw new Error("completed workflow has active calls");
|
|
1079
1262
|
}
|
|
1080
1263
|
if (
|
|
1081
1264
|
run.status === "stopped" &&
|
|
@@ -1126,22 +1309,44 @@ export function snapshotRun(run: ScriptRun): ScriptRun {
|
|
|
1126
1309
|
*
|
|
1127
1310
|
* The runtime keys its cache by `${runId}:${callIndex}` (the same namespacing
|
|
1128
1311
|
* the store deltas use), so a nested workflow() call with its own runId can
|
|
1129
|
-
* never replay the parent's entry. Every `call_result`
|
|
1130
|
-
* `callHash` is a replayable
|
|
1312
|
+
* never replay the parent's entry. Every `call_result` or `workflow_result`
|
|
1313
|
+
* event that carries a `callHash` is a replayable fact; entries without a hash
|
|
1131
1314
|
* (legacy or malformed) are skipped rather than quarantined — a resume must
|
|
1132
1315
|
* tolerate a foreign or partial journal and simply re-run the misses live.
|
|
1133
1316
|
*/
|
|
1134
|
-
export function buildResumeJournal(
|
|
1135
|
-
|
|
1136
|
-
|
|
1317
|
+
export function buildResumeJournal(entries: readonly SessionEntryLike[]): Map<
|
|
1318
|
+
string,
|
|
1319
|
+
{
|
|
1320
|
+
index: number;
|
|
1321
|
+
runId?: string;
|
|
1322
|
+
hash: string;
|
|
1323
|
+
result: unknown;
|
|
1324
|
+
storeDelta?: Record<string, unknown>;
|
|
1325
|
+
generation?: number;
|
|
1326
|
+
agentCount?: number;
|
|
1327
|
+
}
|
|
1328
|
+
> {
|
|
1137
1329
|
const journal = new Map<
|
|
1138
1330
|
string,
|
|
1139
|
-
{
|
|
1331
|
+
{
|
|
1332
|
+
index: number;
|
|
1333
|
+
runId?: string;
|
|
1334
|
+
hash: string;
|
|
1335
|
+
result: unknown;
|
|
1336
|
+
storeDelta?: Record<string, unknown>;
|
|
1337
|
+
generation?: number;
|
|
1338
|
+
agentCount?: number;
|
|
1339
|
+
}
|
|
1140
1340
|
>();
|
|
1141
1341
|
for (const entry of entries) {
|
|
1142
1342
|
if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
|
|
1143
1343
|
const raw = entry.data;
|
|
1144
|
-
if (
|
|
1344
|
+
if (
|
|
1345
|
+
!isRecord(raw) ||
|
|
1346
|
+
(raw.kind !== "call_result" && raw.kind !== "workflow_result") ||
|
|
1347
|
+
typeof raw.callHash !== "string"
|
|
1348
|
+
)
|
|
1349
|
+
continue;
|
|
1145
1350
|
if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) continue;
|
|
1146
1351
|
const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
|
|
1147
1352
|
let nodeId: string;
|
|
@@ -1155,15 +1360,49 @@ export function buildResumeJournal(
|
|
|
1155
1360
|
// replay values. Replaying them as `undefined` would silently skip a call
|
|
1156
1361
|
// and bypass the runtime's retry/error policy.
|
|
1157
1362
|
if (result?.status !== "completed" || !Object.hasOwn(result, "result")) continue;
|
|
1158
|
-
|
|
1363
|
+
const generation =
|
|
1364
|
+
raw.kind === "workflow_result" && raw.generation !== undefined
|
|
1365
|
+
? typeof raw.generation === "number" &&
|
|
1366
|
+
Number.isInteger(raw.generation) &&
|
|
1367
|
+
raw.generation >= 1 &&
|
|
1368
|
+
raw.generation <= 1_000_000
|
|
1369
|
+
? raw.generation
|
|
1370
|
+
: undefined
|
|
1371
|
+
: undefined;
|
|
1372
|
+
if (raw.kind === "workflow_result" && generation === undefined) continue;
|
|
1373
|
+
const agentCount =
|
|
1374
|
+
raw.kind === "workflow_result" && raw.agentCount !== undefined
|
|
1375
|
+
? typeof raw.agentCount === "number" &&
|
|
1376
|
+
Number.isInteger(raw.agentCount) &&
|
|
1377
|
+
raw.agentCount >= 0 &&
|
|
1378
|
+
raw.agentCount <= JOURNAL_CALL_LIMIT
|
|
1379
|
+
? raw.agentCount
|
|
1380
|
+
: undefined
|
|
1381
|
+
: undefined;
|
|
1382
|
+
if (raw.kind === "workflow_result" && raw.agentCount !== undefined && agentCount === undefined) continue;
|
|
1383
|
+
const candidate = {
|
|
1159
1384
|
index: Number(nodeId),
|
|
1160
1385
|
runId,
|
|
1161
1386
|
hash: raw.callHash,
|
|
1162
1387
|
result: result.result,
|
|
1388
|
+
...(generation === undefined ? {} : { generation }),
|
|
1389
|
+
...(agentCount === undefined ? {} : { agentCount }),
|
|
1163
1390
|
...(isRecord(raw.storeDelta) && Object.keys(raw.storeDelta).length > 0
|
|
1164
1391
|
? { storeDelta: raw.storeDelta as Record<string, unknown> }
|
|
1165
1392
|
: {}),
|
|
1166
|
-
}
|
|
1393
|
+
};
|
|
1394
|
+
const key = `${runId}:${nodeId}`;
|
|
1395
|
+
const existing = journal.get(key);
|
|
1396
|
+
if (generation !== undefined && existing?.generation !== undefined) {
|
|
1397
|
+
if (generation < existing.generation) continue;
|
|
1398
|
+
if (generation === existing.generation) {
|
|
1399
|
+
if (JSON.stringify(existing) !== JSON.stringify(candidate)) {
|
|
1400
|
+
throw new Error("contradictory nested workflow result");
|
|
1401
|
+
}
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
journal.set(key, candidate);
|
|
1167
1406
|
}
|
|
1168
1407
|
return journal;
|
|
1169
1408
|
}
|