@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/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 resume rebuilds the
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 type { WorkflowMeta } from "./runtime.js";
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 = 1_000;
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 parseEvent(raw: unknown, runs: Map<string, ScriptRun>): JournalEvent {
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.isInteger(raw.frozenMaxAgents) && raw.frozenMaxAgents >= 1
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.isInteger(raw.frozenConcurrency) && raw.frozenConcurrency >= 1
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.isInteger(raw.frozenAgentRetries) &&
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 (run.status === "completed" && statuses.some((status) => status !== "completed")) {
1078
- throw new Error("completed workflow has non-completed calls");
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` event that carries a
1130
- * `callHash` is a replayable agent()/checkpoint() fact; entries without a hash
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
- entries: readonly SessionEntryLike[],
1136
- ): Map<string, { index: number; runId?: string; hash: string; result: unknown; storeDelta?: Record<string, unknown> }> {
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
- { index: number; runId?: string; hash: string; result: unknown; storeDelta?: Record<string, unknown> }
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 (!isRecord(raw) || raw.kind !== "call_result" || typeof raw.callHash !== "string") continue;
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
- journal.set(`${runId}:${nodeId}`, {
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
  }