@signalridge/pi-workflows 0.2.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 ADDED
@@ -0,0 +1,1584 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ManagedOwner } from "@signalridge/pi-subagents-protocol";
3
+ import { validateWorkflow, type WorkflowDefinition } from "./schema.js";
4
+ import {
5
+ canTransitionTask,
6
+ canTransitionWorkflow,
7
+ isTerminalTask,
8
+ type TaskStatus,
9
+ type WorkflowStatus,
10
+ } from "./state-machine.js";
11
+
12
+ export const JOURNAL_ENTRY_TYPE = "pi-workflows:journal";
13
+ export const JOURNAL_SCHEMA_VERSION = 2;
14
+ export const JOURNAL_LEGACY_SCHEMA_VERSION = 1;
15
+ export type JournalSchemaVersion = 1 | 2;
16
+ export const JOURNAL_TEXT_LIMIT = 8_000;
17
+ export const JOURNAL_ERROR_LIMIT = 2_000;
18
+ export const JOURNAL_ID_LIMIT = 256;
19
+ export const JOURNAL_TIMESTAMP_LIMIT = 8_640_000_000_000_000;
20
+ export const JOURNAL_RECOVERY_ID_PREFIX = "r1-";
21
+ export const JOURNAL_RECOVERY_ID_LIMIT = JOURNAL_RECOVERY_ID_PREFIX.length + 64;
22
+ /** Maximum number of distinct atomic recoveries retained while replaying one run. */
23
+ export const JOURNAL_RECOVERY_SEEN_LIMIT = 4_096;
24
+ const RECOVERY_ID_PATTERN = /^r1-[0-9a-f]{64}$/;
25
+
26
+ export class StaleJournalGenerationError extends Error {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = "StaleJournalGenerationError";
30
+ }
31
+ }
32
+
33
+ const WORKFLOW_STATUS_VALUES = new Set<WorkflowStatus>([
34
+ "pending",
35
+ "running",
36
+ "pausing",
37
+ "paused",
38
+ "synthesizing",
39
+ "stopping",
40
+ "completed",
41
+ "failed",
42
+ "stopped",
43
+ "interrupted",
44
+ ]);
45
+ const TASK_STATUS_VALUES = new Set<TaskStatus>([
46
+ "pending",
47
+ "ready",
48
+ "dispatching",
49
+ "queued",
50
+ "running",
51
+ "completed",
52
+ "failed",
53
+ "stopped",
54
+ "blocked",
55
+ ]);
56
+ const JOURNAL_KINDS = new Set([
57
+ "run_created",
58
+ "workflow_transition",
59
+ "run_recovery",
60
+ "terminal_recovery",
61
+ "attempt_recovery",
62
+ "task_transition",
63
+ "task_attempt",
64
+ "task_result",
65
+ "task_compacted",
66
+ "synthesis_result",
67
+ ]);
68
+
69
+ /** One atomic recovery event may rotate every active node in a run. */
70
+ export const JOURNAL_RECOVERY_NODE_LIMIT = 129; // 128 tasks plus synthesis
71
+ const JOURNAL_GENERATION_LIMIT = 1_000_000;
72
+ export type WorkflowOwner = ManagedOwner;
73
+
74
+ export interface WorkflowTaskResult {
75
+ status: "completed" | "failed" | "stopped";
76
+ agentId?: string;
77
+ attemptId?: string;
78
+ text?: string;
79
+ error?: string;
80
+ /** Persisted output transcript pointer, when pi-subagents provided one. */
81
+ outputFile?: string;
82
+ /** Total input + output token count, when lifecycle accounting provided it. */
83
+ tokenCount?: number;
84
+ compactionCount: number;
85
+ truncated?: boolean;
86
+ updatedAt: number;
87
+ }
88
+
89
+ export interface WorkflowRun {
90
+ runId: string;
91
+ schemaVersion: number;
92
+ definition: WorkflowDefinition;
93
+ status: WorkflowStatus;
94
+ taskStatus: Record<string, TaskStatus>;
95
+ agentIds: Record<string, string>;
96
+ /** Monotonic per-node attempt generation; completed facts are never reset. */
97
+ attempts: Record<string, number>;
98
+ attemptIds: Record<string, string>;
99
+ /** True when the journal carries generation-specific owner IDs. */
100
+ attemptTracking?: boolean;
101
+ taskResults: Record<string, WorkflowTaskResult>;
102
+ compactions: Record<string, number>;
103
+ startedAt: number;
104
+ updatedAt: number;
105
+ synthesisAgentId?: string;
106
+ synthesisResult?: WorkflowTaskResult;
107
+ error?: string;
108
+ nonResumable?: boolean;
109
+ /** Terminal cleanup intent while active agents settle. */
110
+ terminalIntent?: "stop" | "failure";
111
+ }
112
+
113
+ interface JournalBase {
114
+ schemaVersion: JournalSchemaVersion;
115
+ runId: string;
116
+ timestamp: number;
117
+ }
118
+
119
+ /** State that was observed immediately before an atomic recovery rotation. */
120
+ export type RecoverySourceStatus = TaskStatus | "synthesizing";
121
+
122
+ /**
123
+ * A generation rotation is self-contained: replay can clear the superseded
124
+ * attempt and make the new attempt ready without relying on a following
125
+ * task_transition entry.
126
+ */
127
+ export interface RecoveryRotation {
128
+ nodeId: string;
129
+ sourceAttemptId: string;
130
+ sourceGeneration: number;
131
+ sourceStatus: RecoverySourceStatus;
132
+ attemptId: string;
133
+ generation: number;
134
+ owner: WorkflowOwner;
135
+ supersededAgentId?: string;
136
+ }
137
+
138
+ export interface RecoveryTerminalResult {
139
+ nodeId: string;
140
+ attemptId: string;
141
+ result: WorkflowTaskResult;
142
+ owner: WorkflowOwner;
143
+ }
144
+
145
+ export interface RunRecoveryEvent extends JournalBase {
146
+ kind: "run_recovery";
147
+ status: "interrupted";
148
+ branchGeneration: number;
149
+ rotations: RecoveryRotation[];
150
+ terminalResults?: RecoveryTerminalResult[];
151
+ recoveryId: string;
152
+ /** Set only when reading a schema-v2 atomic event written before recoveryId existed. */
153
+ legacyMigration?: true;
154
+ timestamp: number;
155
+ }
156
+ /**
157
+ * A terminal-intent recovery is one durable state transition. It settles every
158
+ * unresolved node before publishing the final workflow outcome, so replay never
159
+ * has to infer a terminal state from a prefix of result/transition events.
160
+ */
161
+ export interface TerminalRecoveryEvent extends JournalBase {
162
+ kind: "terminal_recovery";
163
+ status: "failed" | "stopped";
164
+ terminalIntent: "failure" | "stop";
165
+ branchGeneration: number;
166
+ terminalResults: RecoveryTerminalResult[];
167
+ blockedNodeIds: string[];
168
+ error?: string;
169
+ recoveryId: string;
170
+ timestamp: number;
171
+ }
172
+
173
+ export interface AttemptRecoveryEvent extends JournalBase {
174
+ kind: "attempt_recovery";
175
+ nodeId: string;
176
+ branchGeneration: number;
177
+ rotation: RecoveryRotation;
178
+ /** Only used to migrate a legacy prefix ending after workflow_transition. */
179
+ legacyMigration?: true;
180
+ }
181
+
182
+ export type JournalEvent =
183
+ | (JournalBase & {
184
+ kind: "run_created";
185
+ definition: WorkflowDefinition;
186
+ attempts?: Record<string, number>;
187
+ attemptIds?: Record<string, string>;
188
+ })
189
+ | (JournalBase & {
190
+ kind: "workflow_transition";
191
+ status: WorkflowStatus;
192
+ error?: string;
193
+ terminalIntent?: "stop" | "failure";
194
+ })
195
+ | RunRecoveryEvent
196
+ | TerminalRecoveryEvent
197
+ | AttemptRecoveryEvent
198
+ | (JournalBase & {
199
+ kind: "task_transition";
200
+ nodeId: string;
201
+ status: TaskStatus;
202
+ agentId?: string;
203
+ attemptId?: string;
204
+ owner?: WorkflowOwner;
205
+ })
206
+ | (JournalBase & {
207
+ kind: "task_attempt";
208
+ nodeId: string;
209
+ attemptId: string;
210
+ generation: number;
211
+ owner?: WorkflowOwner;
212
+ })
213
+ | (JournalBase & {
214
+ kind: "task_result";
215
+ nodeId: string;
216
+ result: WorkflowTaskResult;
217
+ attemptId?: string;
218
+ owner?: WorkflowOwner;
219
+ })
220
+ | (JournalBase & {
221
+ kind: "task_compacted";
222
+ nodeId: string;
223
+ compactionCount: number;
224
+ attemptId?: string;
225
+ owner?: WorkflowOwner;
226
+ })
227
+ | (JournalBase & {
228
+ kind: "synthesis_result";
229
+ result: WorkflowTaskResult;
230
+ attemptId?: string;
231
+ owner?: WorkflowOwner;
232
+ });
233
+
234
+ export interface JournalWriter {
235
+ append(event: JournalEvent): void;
236
+ }
237
+
238
+ export interface SessionEntryLike {
239
+ type?: unknown;
240
+ customType?: unknown;
241
+ data?: unknown;
242
+ }
243
+
244
+ export interface ReplayJournalOptions {
245
+ /** Called at most once for malformed/forged/future journal data. */
246
+ onInvalid?: (diagnostic: string) => void;
247
+ }
248
+
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === "object" && value !== null && !Array.isArray(value);
251
+ }
252
+
253
+ function boundedString(value: unknown, label: string, limit: number): string {
254
+ if (typeof value !== "string" || value.length === 0 || value.length > limit) {
255
+ throw new Error(`${label} is invalid or exceeds ${limit} characters`);
256
+ }
257
+ return value;
258
+ }
259
+
260
+ function validTimestamp(value: unknown, label: string): number {
261
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > JOURNAL_TIMESTAMP_LIMIT) {
262
+ throw new Error(`${label} is invalid`);
263
+ }
264
+ return value;
265
+ }
266
+
267
+ function assertKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
268
+ const accepted = new Set(allowed);
269
+ for (const key of Object.keys(value)) {
270
+ if (!accepted.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
271
+ }
272
+ }
273
+
274
+ export type RecoveryIdInput =
275
+ | (Pick<RunRecoveryEvent, "runId" | "status" | "branchGeneration" | "rotations" | "terminalResults"> & {
276
+ kind?: "run_recovery";
277
+ })
278
+ | Pick<
279
+ TerminalRecoveryEvent,
280
+ | "kind"
281
+ | "runId"
282
+ | "status"
283
+ | "terminalIntent"
284
+ | "branchGeneration"
285
+ | "terminalResults"
286
+ | "blockedNodeIds"
287
+ | "error"
288
+ >;
289
+
290
+ function canonicalJson(value: unknown): string {
291
+ if (value === null) return "null";
292
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
293
+ return JSON.stringify(value);
294
+ }
295
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
296
+ if (isRecord(value)) {
297
+ return `{${Object.keys(value)
298
+ .sort()
299
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
300
+ .join(",")}}`;
301
+ }
302
+ throw new Error("recovery semantic payload contains an unsupported value");
303
+ }
304
+
305
+ function digestCanonical(value: unknown): string {
306
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
307
+ }
308
+
309
+ function compareStrings(left: string, right: string): number {
310
+ return left < right ? -1 : left > right ? 1 : 0;
311
+ }
312
+
313
+ function sortedRecoveryRotations(rotations: readonly RecoveryRotation[]): RecoveryRotation[] {
314
+ return [...rotations].sort((left, right) => {
315
+ const nodeOrder = compareStrings(left.nodeId, right.nodeId);
316
+ if (nodeOrder !== 0) return nodeOrder;
317
+ const attemptOrder = compareStrings(left.sourceAttemptId, right.sourceAttemptId);
318
+ if (attemptOrder !== 0) return attemptOrder;
319
+ return left.sourceGeneration - right.sourceGeneration;
320
+ });
321
+ }
322
+
323
+ function sortedRecoveryTerminals(terminals: readonly RecoveryTerminalResult[]): RecoveryTerminalResult[] {
324
+ return [...terminals].sort((left, right) => {
325
+ const nodeOrder = compareStrings(left.nodeId, right.nodeId);
326
+ if (nodeOrder !== 0) return nodeOrder;
327
+ return compareStrings(left.attemptId, right.attemptId);
328
+ });
329
+ }
330
+
331
+ function recoverySemanticPayload(event: RecoveryIdInput): Record<string, unknown> {
332
+ if ("kind" in event && event.kind === "terminal_recovery") {
333
+ return {
334
+ blockedNodeIds: [...event.blockedNodeIds].sort(compareStrings),
335
+ branchGeneration: event.branchGeneration,
336
+ ...(event.error === undefined ? {} : { error: event.error }),
337
+ kind: event.kind,
338
+ runId: event.runId,
339
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
340
+ status: event.status,
341
+ terminalIntent: event.terminalIntent,
342
+ terminalResults: sortedRecoveryTerminals(event.terminalResults),
343
+ };
344
+ }
345
+ return {
346
+ branchGeneration: event.branchGeneration,
347
+ kind: "run_recovery",
348
+ rotations: sortedRecoveryRotations(event.rotations),
349
+ runId: event.runId,
350
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
351
+ status: event.status,
352
+ terminalResults: sortedRecoveryTerminals(event.terminalResults ?? []),
353
+ };
354
+ }
355
+
356
+ /** Bounded digest of all normalized semantic fields, excluding timestamp and migration metadata. */
357
+ export function recoverySemanticFingerprint(event: RecoveryIdInput): string {
358
+ return `s1-${digestCanonical(recoverySemanticPayload(event))}`;
359
+ }
360
+
361
+ /**
362
+ * Derive the durable recovery identity from bounded source/target metadata.
363
+ * Terminal recovery includes the complete terminal fact set because it is the
364
+ * state transition, not merely a generation rotation.
365
+ */
366
+ export function deriveRecoveryId(event: RecoveryIdInput): string {
367
+ if ("kind" in event && event.kind === "terminal_recovery") {
368
+ return `${JOURNAL_RECOVERY_ID_PREFIX}${digestCanonical({
369
+ blockedNodeIds: [...event.blockedNodeIds].sort(compareStrings),
370
+ branchNamespace: event.branchGeneration,
371
+ ...(event.error === undefined ? {} : { error: event.error }),
372
+ runId: event.runId,
373
+ status: event.status,
374
+ terminalIntent: event.terminalIntent,
375
+ terminalResults: sortedRecoveryTerminals(event.terminalResults),
376
+ version: 1,
377
+ })}`;
378
+ }
379
+ const rotations = sortedRecoveryRotations(event.rotations);
380
+ const terminals = sortedRecoveryTerminals(event.terminalResults ?? []);
381
+ const identity = {
382
+ branchNamespace: event.branchGeneration,
383
+ runId: event.runId,
384
+ sources: rotations.map((rotation) => ({
385
+ nodeId: rotation.nodeId,
386
+ sourceAttemptId: rotation.sourceAttemptId,
387
+ sourceGeneration: rotation.sourceGeneration,
388
+ sourceStatus: rotation.sourceStatus,
389
+ ...(rotation.supersededAgentId === undefined ? {} : { supersededAgentId: rotation.supersededAgentId }),
390
+ })),
391
+ status: event.status,
392
+ targets: rotations.map((rotation) => ({
393
+ nodeId: rotation.nodeId,
394
+ attemptId: rotation.attemptId,
395
+ generation: rotation.generation,
396
+ owner: rotation.owner,
397
+ ...(rotation.supersededAgentId === undefined ? {} : { supersededAgentId: rotation.supersededAgentId }),
398
+ })),
399
+ terminalNodes: terminals.map((terminal) => ({
400
+ nodeId: terminal.nodeId,
401
+ attemptId: terminal.attemptId,
402
+ status: terminal.result.status,
403
+ ...(terminal.result.agentId === undefined ? {} : { agentId: terminal.result.agentId }),
404
+ ...(terminal.result.outputFile === undefined ? {} : { outputFile: terminal.result.outputFile }),
405
+ ...(terminal.result.tokenCount === undefined ? {} : { tokenCount: terminal.result.tokenCount }),
406
+ compactionCount: terminal.result.compactionCount,
407
+ ...(terminal.result.truncated === undefined ? {} : { truncated: terminal.result.truncated }),
408
+ })),
409
+ version: 1,
410
+ };
411
+ return `${JOURNAL_RECOVERY_ID_PREFIX}${digestCanonical(identity)}`;
412
+ }
413
+
414
+ function parseRecoveryId(value: unknown, label: string): string {
415
+ const recoveryId = boundedString(value, label, JOURNAL_RECOVERY_ID_LIMIT);
416
+ if (!RECOVERY_ID_PATTERN.test(recoveryId)) throw new Error(`${label} has an invalid format`);
417
+ return recoveryId;
418
+ }
419
+ function parseOwner(value: unknown, runId: string, nodeId: string): WorkflowOwner {
420
+ if (!isRecord(value)) throw new Error("owner must be an object");
421
+ assertKeys(value, ["extension", "runId", "nodeId", "attemptId"], "owner");
422
+ const extension = boundedString(value.extension, "owner.extension", 64);
423
+ const ownerRunId = boundedString(value.runId, "owner.runId", JOURNAL_ID_LIMIT);
424
+ const ownerNodeId = boundedString(value.nodeId, "owner.nodeId", JOURNAL_ID_LIMIT);
425
+ const attemptId =
426
+ value.attemptId === undefined ? undefined : boundedString(value.attemptId, "owner.attemptId", JOURNAL_ID_LIMIT);
427
+ if (extension !== "pi-workflows" || ownerRunId !== runId || ownerNodeId !== nodeId) {
428
+ throw new Error("journal owner does not match its workflow node");
429
+ }
430
+ return {
431
+ extension: "pi-workflows",
432
+ runId: ownerRunId,
433
+ nodeId: ownerNodeId,
434
+ ...(attemptId ? { attemptId } : {}),
435
+ };
436
+ }
437
+
438
+ function parseResult(value: unknown, label: string): WorkflowTaskResult {
439
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
440
+ assertKeys(
441
+ value,
442
+ [
443
+ "status",
444
+ "agentId",
445
+ "attemptId",
446
+ "text",
447
+ "error",
448
+ "outputFile",
449
+ "tokenCount",
450
+ "compactionCount",
451
+ "truncated",
452
+ "updatedAt",
453
+ ],
454
+ label,
455
+ );
456
+ const status = value.status;
457
+ if (status !== "completed" && status !== "failed" && status !== "stopped") {
458
+ throw new Error(`${label}.status is invalid`);
459
+ }
460
+ const compactionCount = value.compactionCount;
461
+ if (typeof compactionCount !== "number" || !Number.isInteger(compactionCount) || compactionCount < 0) {
462
+ throw new Error(`${label}.compactionCount is invalid`);
463
+ }
464
+ const tokenCount = value.tokenCount;
465
+ if (tokenCount !== undefined && (typeof tokenCount !== "number" || !Number.isInteger(tokenCount) || tokenCount < 0)) {
466
+ throw new Error(`${label}.tokenCount is invalid`);
467
+ }
468
+ if (value.truncated !== undefined && typeof value.truncated !== "boolean") {
469
+ throw new Error(`${label}.truncated is invalid`);
470
+ }
471
+ return {
472
+ status,
473
+ ...(value.agentId === undefined
474
+ ? {}
475
+ : { agentId: boundedString(value.agentId, `${label}.agentId`, JOURNAL_ID_LIMIT) }),
476
+ ...(value.attemptId === undefined
477
+ ? {}
478
+ : { attemptId: boundedString(value.attemptId, `${label}.attemptId`, JOURNAL_ID_LIMIT) }),
479
+ ...(value.text === undefined ? {} : { text: boundedString(value.text, `${label}.text`, JOURNAL_TEXT_LIMIT + 32) }),
480
+ ...(value.error === undefined
481
+ ? {}
482
+ : { error: boundedString(value.error, `${label}.error`, JOURNAL_ERROR_LIMIT + 32) }),
483
+ ...(value.outputFile === undefined
484
+ ? {}
485
+ : { outputFile: boundedString(value.outputFile, `${label}.outputFile`, JOURNAL_ID_LIMIT) }),
486
+ ...(tokenCount === undefined ? {} : { tokenCount }),
487
+ compactionCount,
488
+ ...(value.truncated === undefined ? {} : { truncated: value.truncated }),
489
+ updatedAt: validTimestamp(value.updatedAt, `${label}.updatedAt`),
490
+ };
491
+ }
492
+
493
+ function taskNodeIds(run: WorkflowRun): Set<string> {
494
+ return new Set([...run.definition.tasks.map((task) => task.id), "__synthesis__"]);
495
+ }
496
+
497
+ function parseAttemptMap(value: unknown, definition: WorkflowDefinition, label: string): Record<string, number> {
498
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
499
+ const allowed = new Set([...definition.tasks.map((task) => task.id), "__synthesis__"]);
500
+ const result: Record<string, number> = {};
501
+ for (const [nodeId, generation] of Object.entries(value)) {
502
+ if (!allowed.has(nodeId)) throw new Error(`${label} contains an unknown node`);
503
+ if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 1 || generation > 1_000_000) {
504
+ throw new Error(`${label}.${nodeId} is invalid`);
505
+ }
506
+ result[nodeId] = generation;
507
+ }
508
+ return result;
509
+ }
510
+
511
+ function parseAttemptIdMap(value: unknown, definition: WorkflowDefinition, label: string): Record<string, string> {
512
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
513
+ const allowed = new Set([...definition.tasks.map((task) => task.id), "__synthesis__"]);
514
+ const result: Record<string, string> = {};
515
+ for (const [nodeId, attemptId] of Object.entries(value)) {
516
+ if (!allowed.has(nodeId)) throw new Error(`${label} contains an unknown node`);
517
+ result[nodeId] = boundedString(attemptId, `${label}.${nodeId}`, JOURNAL_ID_LIMIT);
518
+ }
519
+ return result;
520
+ }
521
+
522
+ function parseBranchGeneration(value: unknown, label: string): number {
523
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
524
+ throw new Error(`${label} is invalid`);
525
+ }
526
+ return value;
527
+ }
528
+
529
+ function recoverableTaskStatus(status: TaskStatus): boolean {
530
+ return (
531
+ status === "pending" ||
532
+ status === "ready" ||
533
+ status === "dispatching" ||
534
+ status === "queued" ||
535
+ status === "running"
536
+ );
537
+ }
538
+
539
+ function activeTaskStatus(status: TaskStatus): boolean {
540
+ return status === "dispatching" || status === "queued" || status === "running";
541
+ }
542
+
543
+ function parseRecoveryRotationShape(
544
+ value: unknown,
545
+ runId: string,
546
+ nodes: ReadonlySet<string>,
547
+ label: string,
548
+ ): RecoveryRotation {
549
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
550
+ assertKeys(
551
+ value,
552
+ [
553
+ "nodeId",
554
+ "sourceAttemptId",
555
+ "sourceGeneration",
556
+ "sourceStatus",
557
+ "attemptId",
558
+ "generation",
559
+ "owner",
560
+ "supersededAgentId",
561
+ ],
562
+ label,
563
+ );
564
+ const nodeId = boundedString(value.nodeId, `${label}.nodeId`, JOURNAL_ID_LIMIT);
565
+ if (!nodes.has(nodeId)) throw new Error(`${label}.nodeId references an unknown node`);
566
+ const sourceAttemptId = boundedString(value.sourceAttemptId, `${label}.sourceAttemptId`, JOURNAL_ID_LIMIT);
567
+ const sourceGeneration = value.sourceGeneration;
568
+ if (
569
+ typeof sourceGeneration !== "number" ||
570
+ !Number.isInteger(sourceGeneration) ||
571
+ sourceGeneration < 1 ||
572
+ sourceGeneration > JOURNAL_GENERATION_LIMIT
573
+ ) {
574
+ throw new Error(`${label}.sourceGeneration is invalid`);
575
+ }
576
+ const sourceStatus = value.sourceStatus;
577
+ if (nodeId === "__synthesis__") {
578
+ if (sourceStatus !== "synthesizing") throw new Error(`${label}.sourceStatus is invalid for synthesis`);
579
+ } else if (typeof sourceStatus !== "string" || !TASK_STATUS_VALUES.has(sourceStatus as TaskStatus)) {
580
+ throw new Error(`${label}.sourceStatus is invalid`);
581
+ }
582
+ const generation = value.generation;
583
+ if (
584
+ typeof generation !== "number" ||
585
+ !Number.isInteger(generation) ||
586
+ generation !== sourceGeneration + 1 ||
587
+ generation > JOURNAL_GENERATION_LIMIT
588
+ ) {
589
+ throw new Error(`${label}.generation is invalid or not the next generation`);
590
+ }
591
+ const attemptId = boundedString(value.attemptId, `${label}.attemptId`, JOURNAL_ID_LIMIT);
592
+ const expectedAttemptId = `${runId}/${nodeId}/attempt-${generation}`;
593
+ if (attemptId !== expectedAttemptId) throw new Error(`${label}.attemptId does not match its generation`);
594
+ const owner = parseOwner(value.owner, runId, nodeId);
595
+ if (owner.attemptId !== attemptId) throw new Error(`${label}.owner must identify the new attempt`);
596
+ const supersededAgentId =
597
+ value.supersededAgentId === undefined
598
+ ? undefined
599
+ : boundedString(value.supersededAgentId, `${label}.supersededAgentId`, JOURNAL_ID_LIMIT);
600
+ return {
601
+ nodeId,
602
+ sourceAttemptId,
603
+ sourceGeneration,
604
+ sourceStatus: sourceStatus as RecoverySourceStatus,
605
+ attemptId,
606
+ generation,
607
+ owner,
608
+ ...(supersededAgentId === undefined ? {} : { supersededAgentId }),
609
+ };
610
+ }
611
+
612
+ function parseRecoveryRotation(
613
+ value: unknown,
614
+ run: WorkflowRun,
615
+ label: string,
616
+ allowInterruptedSynthesis = false,
617
+ ): RecoveryRotation {
618
+ const rotation = parseRecoveryRotationShape(value, run.runId, taskNodeIds(run), label);
619
+ const { nodeId, sourceAttemptId, sourceGeneration, sourceStatus } = rotation;
620
+ if (run.attemptIds[nodeId] !== sourceAttemptId) {
621
+ throw new Error(`${label}.sourceAttemptId does not match the current attempt`);
622
+ }
623
+ if (run.attempts[nodeId] !== sourceGeneration) {
624
+ throw new Error(`${label}.sourceGeneration does not match the current generation`);
625
+ }
626
+ if (nodeId === "__synthesis__") {
627
+ const synthesisSourceAllowed =
628
+ run.status === "synthesizing" || (allowInterruptedSynthesis && run.status === "interrupted");
629
+ if (sourceStatus !== "synthesizing" || !synthesisSourceAllowed) {
630
+ throw new Error(`${label}.sourceStatus is invalid for synthesis`);
631
+ }
632
+ } else {
633
+ const taskStatus = run.taskStatus[nodeId];
634
+ if (taskStatus !== sourceStatus || !recoverableTaskStatus(taskStatus)) {
635
+ throw new Error(`${label}.sourceStatus does not match the current task`);
636
+ }
637
+ }
638
+ return rotation;
639
+ }
640
+
641
+ function parseRecoveryRotationsShape(
642
+ raw: unknown,
643
+ runId: string,
644
+ nodes: ReadonlySet<string>,
645
+ terminalNodes: ReadonlySet<string> = new Set(),
646
+ ): RecoveryRotation[] {
647
+ if (!Array.isArray(raw)) throw new Error("journal recovery rotations must be an array");
648
+ if (raw.length > JOURNAL_RECOVERY_NODE_LIMIT) throw new Error("journal recovery has too many rotations");
649
+ const seen = new Set<string>();
650
+ return raw.map((value, index) => {
651
+ const rotation = parseRecoveryRotationShape(value, runId, nodes, `rotations[${index}]`);
652
+ if (seen.has(rotation.nodeId)) throw new Error("journal recovery contains duplicate node rotations");
653
+ if (terminalNodes.has(rotation.nodeId)) throw new Error("journal recovery both settles and rotates a node");
654
+ seen.add(rotation.nodeId);
655
+ return rotation;
656
+ });
657
+ }
658
+
659
+ function parseRecoveryRotations(
660
+ raw: unknown,
661
+ run: WorkflowRun,
662
+ terminalNodes: ReadonlySet<string> = new Set(),
663
+ ): RecoveryRotation[] {
664
+ if (!Array.isArray(raw)) throw new Error("journal recovery rotations must be an array");
665
+ if (raw.length > JOURNAL_RECOVERY_NODE_LIMIT) throw new Error("journal recovery has too many rotations");
666
+ const seen = new Set<string>();
667
+ const rotations = raw.map((value, index) => {
668
+ const rotation = parseRecoveryRotation(value, run, `rotations[${index}]`);
669
+ if (seen.has(rotation.nodeId)) throw new Error("journal recovery contains duplicate node rotations");
670
+ if (terminalNodes.has(rotation.nodeId)) throw new Error("journal recovery both settles and rotates a node");
671
+ seen.add(rotation.nodeId);
672
+ return rotation;
673
+ });
674
+ for (const task of run.definition.tasks) {
675
+ if (!activeTaskStatus(run.taskStatus[task.id]) || terminalNodes.has(task.id)) continue;
676
+ if (!seen.has(task.id)) throw new Error(`journal recovery omits active task ${task.id}`);
677
+ }
678
+ if (
679
+ run.status === "synthesizing" &&
680
+ !run.synthesisResult &&
681
+ !terminalNodes.has("__synthesis__") &&
682
+ !seen.has("__synthesis__")
683
+ ) {
684
+ throw new Error("journal recovery omits active synthesis");
685
+ }
686
+ return rotations;
687
+ }
688
+
689
+ function parseRecoveryTerminalResultShape(
690
+ value: unknown,
691
+ runId: string,
692
+ nodes: ReadonlySet<string>,
693
+ label: string,
694
+ ): RecoveryTerminalResult {
695
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
696
+ assertKeys(value, ["nodeId", "attemptId", "result", "owner"], label);
697
+ const nodeId = boundedString(value.nodeId, `${label}.nodeId`, JOURNAL_ID_LIMIT);
698
+ if (!nodes.has(nodeId)) throw new Error("journal recovery terminal result references an unknown node");
699
+ const attemptId = boundedString(value.attemptId, `${label}.attemptId`, JOURNAL_ID_LIMIT);
700
+ const owner = parseOwner(value.owner, runId, nodeId);
701
+ if (owner.attemptId !== attemptId) throw new Error("journal recovery terminal result owner is invalid");
702
+ const parsed = parseResult(value.result, `${label}.result`);
703
+ if (parsed.attemptId !== undefined && parsed.attemptId !== attemptId) {
704
+ throw new Error("journal recovery terminal result payload references a superseded attempt");
705
+ }
706
+ return {
707
+ nodeId,
708
+ attemptId,
709
+ result: parsed.attemptId === undefined ? { ...parsed, attemptId } : parsed,
710
+ owner,
711
+ };
712
+ }
713
+
714
+ function parseRecoveryTerminalResultShapes(
715
+ raw: unknown,
716
+ runId: string,
717
+ nodes: ReadonlySet<string>,
718
+ ): RecoveryTerminalResult[] {
719
+ if (raw === undefined) return [];
720
+ if (!Array.isArray(raw)) throw new Error("journal recovery terminal results must be an array");
721
+ if (raw.length > JOURNAL_RECOVERY_NODE_LIMIT) throw new Error("journal recovery has too many terminal results");
722
+ const seen = new Set<string>();
723
+ return raw.map((value, index) => {
724
+ const terminal = parseRecoveryTerminalResultShape(value, runId, nodes, `terminalResults[${index}]`);
725
+ if (seen.has(terminal.nodeId)) throw new Error("journal recovery contains duplicate terminal results");
726
+ seen.add(terminal.nodeId);
727
+ return terminal;
728
+ });
729
+ }
730
+
731
+ function parseRecoveryTerminalResults(raw: unknown, run: WorkflowRun): RecoveryTerminalResult[] {
732
+ const terminals = parseRecoveryTerminalResultShapes(raw, run.runId, taskNodeIds(run));
733
+ for (const terminal of terminals) {
734
+ const currentStatus = terminal.nodeId === "__synthesis__" ? undefined : run.taskStatus[terminal.nodeId];
735
+ if (terminal.nodeId === "__synthesis__" && run.synthesisResult) {
736
+ throw new Error("journal recovery terminal result references settled synthesis");
737
+ }
738
+ if (currentStatus !== undefined && isTerminalTask(currentStatus)) {
739
+ throw new Error("journal recovery terminal result references a settled task");
740
+ }
741
+ if (terminal.attemptId !== run.attemptIds[terminal.nodeId]) {
742
+ throw new Error("journal recovery terminal result references a superseded attempt");
743
+ }
744
+ }
745
+ return terminals;
746
+ }
747
+
748
+ function parseRunRecovery(raw: Record<string, unknown>, run: WorkflowRun, timestamp: number): RunRecoveryEvent {
749
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("run recovery schema version is unsupported");
750
+ assertKeys(
751
+ raw,
752
+ [
753
+ "kind",
754
+ "schemaVersion",
755
+ "runId",
756
+ "status",
757
+ "branchGeneration",
758
+ "rotations",
759
+ "terminalResults",
760
+ "recoveryId",
761
+ "legacyMigration",
762
+ "timestamp",
763
+ ],
764
+ "run recovery",
765
+ );
766
+ if (raw.legacyMigration !== undefined && raw.legacyMigration !== true) {
767
+ throw new Error("run recovery legacy migration marker is invalid");
768
+ }
769
+ if (run.status === "interrupted" || !["running", "pausing", "synthesizing", "stopping"].includes(run.status)) {
770
+ throw new Error(`invalid workflow recovery source state: ${run.status}`);
771
+ }
772
+ if (run.terminalIntent !== undefined) throw new Error("workflow with terminal intent cannot be recovered");
773
+ if (raw.status !== "interrupted") throw new Error("run recovery status must be interrupted");
774
+ const branchGeneration = parseBranchGeneration(raw.branchGeneration, "branchGeneration");
775
+ const terminalResults = parseRecoveryTerminalResults(raw.terminalResults, run);
776
+ if (Array.isArray(raw.rotations) && raw.rotations.length + terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
777
+ throw new Error("journal recovery affects too many nodes");
778
+ }
779
+ const base = {
780
+ kind: "run_recovery" as const,
781
+ schemaVersion: JOURNAL_SCHEMA_VERSION as 2,
782
+ runId: run.runId,
783
+ status: "interrupted" as const,
784
+ branchGeneration,
785
+ rotations: parseRecoveryRotations(raw.rotations, run, new Set(terminalResults.map((item) => item.nodeId))),
786
+ ...(terminalResults.length > 0 ? { terminalResults } : {}),
787
+ };
788
+ const expectedRecoveryId = deriveRecoveryId(base);
789
+ const legacyMigration = raw.recoveryId === undefined;
790
+ const recoveryId = legacyMigration ? expectedRecoveryId : parseRecoveryId(raw.recoveryId, "recoveryId");
791
+ if (recoveryId !== expectedRecoveryId) {
792
+ throw new Error("run recovery recoveryId does not match its semantic source and target fields");
793
+ }
794
+ return {
795
+ ...base,
796
+ recoveryId,
797
+ ...(legacyMigration ? { legacyMigration: true } : {}),
798
+ timestamp,
799
+ };
800
+ }
801
+
802
+ function parseDuplicateRunRecovery(raw: Record<string, unknown>, run: WorkflowRun): RunRecoveryEvent {
803
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("run recovery schema version is unsupported");
804
+ assertKeys(
805
+ raw,
806
+ [
807
+ "kind",
808
+ "schemaVersion",
809
+ "runId",
810
+ "status",
811
+ "branchGeneration",
812
+ "rotations",
813
+ "terminalResults",
814
+ "recoveryId",
815
+ "legacyMigration",
816
+ "timestamp",
817
+ ],
818
+ "run recovery duplicate",
819
+ );
820
+ if (raw.legacyMigration !== undefined && raw.legacyMigration !== true) {
821
+ throw new Error("run recovery legacy migration marker is invalid");
822
+ }
823
+ const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
824
+ if (runId !== run.runId) throw new Error("run recovery duplicate references a different run");
825
+ if (raw.status !== "interrupted") throw new Error("run recovery status must be interrupted");
826
+ const branchGeneration = parseBranchGeneration(raw.branchGeneration, "branchGeneration");
827
+ const nodes = taskNodeIds(run);
828
+ const terminalResults = parseRecoveryTerminalResultShapes(raw.terminalResults, runId, nodes);
829
+ if (Array.isArray(raw.rotations) && raw.rotations.length + terminalResults.length > JOURNAL_RECOVERY_NODE_LIMIT) {
830
+ throw new Error("journal recovery affects too many nodes");
831
+ }
832
+ const base: RecoveryIdInput = {
833
+ kind: "run_recovery" as const,
834
+ runId,
835
+ status: "interrupted",
836
+ branchGeneration,
837
+ rotations: parseRecoveryRotationsShape(
838
+ raw.rotations,
839
+ runId,
840
+ nodes,
841
+ new Set(terminalResults.map((item) => item.nodeId)),
842
+ ),
843
+ ...(terminalResults.length > 0 ? { terminalResults } : {}),
844
+ };
845
+ const expectedRecoveryId = deriveRecoveryId(base);
846
+ const legacyMigration = raw.recoveryId === undefined;
847
+ const recoveryId = legacyMigration ? expectedRecoveryId : parseRecoveryId(raw.recoveryId, "recoveryId");
848
+ if (recoveryId !== expectedRecoveryId) {
849
+ throw new Error("run recovery recoveryId does not match its semantic source and target fields");
850
+ }
851
+ return {
852
+ ...base,
853
+ kind: "run_recovery",
854
+ schemaVersion: JOURNAL_SCHEMA_VERSION as 2,
855
+ recoveryId,
856
+ ...(legacyMigration ? { legacyMigration: true } : {}),
857
+ timestamp: validTimestamp(raw.timestamp, "timestamp"),
858
+ };
859
+ }
860
+
861
+ function parseBlockedNodeIds(raw: unknown, nodes: ReadonlySet<string>): string[] {
862
+ if (!Array.isArray(raw)) throw new Error("terminal recovery blockedNodeIds must be an array");
863
+ if (raw.length > JOURNAL_RECOVERY_NODE_LIMIT) throw new Error("terminal recovery has too many blocked nodes");
864
+ const seen = new Set<string>();
865
+ return raw.map((value, index) => {
866
+ const nodeId = boundedString(value, `blockedNodeIds[${index}]`, JOURNAL_ID_LIMIT);
867
+ if (nodeId === "__synthesis__" || !nodes.has(nodeId)) {
868
+ throw new Error("terminal recovery blockedNodeIds references an unknown task node");
869
+ }
870
+ if (seen.has(nodeId)) throw new Error("terminal recovery contains duplicate blocked nodes");
871
+ seen.add(nodeId);
872
+ return nodeId;
873
+ });
874
+ }
875
+
876
+ type TerminalRecoveryBase = Omit<TerminalRecoveryEvent, "recoveryId" | "timestamp"> & { recoveryId: string };
877
+
878
+ function parseTerminalRecoveryBase(
879
+ raw: Record<string, unknown>,
880
+ runId: string,
881
+ nodes: ReadonlySet<string>,
882
+ label: string,
883
+ ): TerminalRecoveryBase {
884
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("terminal recovery schema version is unsupported");
885
+ assertKeys(
886
+ raw,
887
+ [
888
+ "kind",
889
+ "schemaVersion",
890
+ "runId",
891
+ "status",
892
+ "terminalIntent",
893
+ "branchGeneration",
894
+ "terminalResults",
895
+ "blockedNodeIds",
896
+ "error",
897
+ "recoveryId",
898
+ "timestamp",
899
+ ],
900
+ label,
901
+ );
902
+ if (raw.kind !== "terminal_recovery") throw new Error(`${label} kind is invalid`);
903
+ const parsedRunId = boundedString(raw.runId, `${label}.runId`, JOURNAL_ID_LIMIT);
904
+ if (parsedRunId !== runId) throw new Error(`${label} references a different workflow run`);
905
+ const statusValue = raw.status;
906
+ if (statusValue !== "failed" && statusValue !== "stopped") throw new Error(`${label}.status is invalid`);
907
+ const status: "failed" | "stopped" = statusValue;
908
+ const terminalIntentValue = raw.terminalIntent;
909
+ if (terminalIntentValue !== "failure" && terminalIntentValue !== "stop") {
910
+ throw new Error(`${label}.terminalIntent is invalid`);
911
+ }
912
+ const terminalIntent: "failure" | "stop" = terminalIntentValue;
913
+ if ((terminalIntent === "failure" && status !== "failed") || (terminalIntent === "stop" && status !== "stopped")) {
914
+ throw new Error(`${label}.status does not match terminalIntent`);
915
+ }
916
+ const branchGeneration = parseBranchGeneration(raw.branchGeneration, `${label}.branchGeneration`);
917
+ if (!Array.isArray(raw.terminalResults)) throw new Error(`${label}.terminalResults must be an array`);
918
+ const terminalResults = parseRecoveryTerminalResultShapes(raw.terminalResults, parsedRunId, nodes);
919
+ const blockedNodeIds = parseBlockedNodeIds(raw.blockedNodeIds, nodes);
920
+ const affected = new Set<string>();
921
+ for (const terminal of terminalResults) {
922
+ if (affected.has(terminal.nodeId)) throw new Error(`${label} settles and blocks the same node`);
923
+ affected.add(terminal.nodeId);
924
+ }
925
+ for (const nodeId of blockedNodeIds) {
926
+ if (affected.has(nodeId)) throw new Error(`${label} settles and blocks the same node`);
927
+ affected.add(nodeId);
928
+ }
929
+ const error = raw.error === undefined ? undefined : boundedString(raw.error, `${label}.error`, JOURNAL_ERROR_LIMIT);
930
+ const base = {
931
+ kind: "terminal_recovery" as const,
932
+ schemaVersion: JOURNAL_SCHEMA_VERSION as 2,
933
+ runId: parsedRunId,
934
+ status,
935
+ terminalIntent,
936
+ branchGeneration,
937
+ terminalResults,
938
+ blockedNodeIds,
939
+ ...(error === undefined ? {} : { error }),
940
+ };
941
+ const expectedRecoveryId = deriveRecoveryId(base);
942
+ const recoveryId = parseRecoveryId(raw.recoveryId, `${label}.recoveryId`);
943
+ if (recoveryId !== expectedRecoveryId) {
944
+ throw new Error(`${label}.recoveryId does not match its semantic terminal fields`);
945
+ }
946
+ return { ...base, recoveryId };
947
+ }
948
+
949
+ function parseTerminalRecovery(
950
+ raw: Record<string, unknown>,
951
+ run: WorkflowRun,
952
+ timestamp: number,
953
+ ): TerminalRecoveryEvent {
954
+ const base = parseTerminalRecoveryBase(raw, run.runId, taskNodeIds(run), "terminal recovery");
955
+ if (run.status !== "stopping" || run.terminalIntent !== base.terminalIntent) {
956
+ throw new Error(`invalid terminal recovery source state: ${run.status}`);
957
+ }
958
+ const terminalNodes = new Set(base.terminalResults.map((terminal) => terminal.nodeId));
959
+ for (const terminal of base.terminalResults) {
960
+ if (terminal.nodeId === "__synthesis__") {
961
+ if (!run.definition.synthesis) throw new Error("terminal recovery references undefined synthesis");
962
+ if (run.synthesisResult && JSON.stringify(run.synthesisResult) !== JSON.stringify(terminal.result)) {
963
+ throw new Error("terminal recovery synthesis fact contradicts an existing result");
964
+ }
965
+ if (!run.synthesisResult && terminal.attemptId !== run.attemptIds.__synthesis__) {
966
+ throw new Error("terminal recovery synthesis fact references a superseded attempt");
967
+ }
968
+ } else {
969
+ const existingResult = run.taskResults[terminal.nodeId];
970
+ if (existingResult && JSON.stringify(existingResult) !== JSON.stringify(terminal.result)) {
971
+ throw new Error("terminal recovery task fact contradicts an existing result");
972
+ }
973
+ if (!existingResult && isTerminalTask(run.taskStatus[terminal.nodeId])) {
974
+ throw new Error("terminal recovery task fact references a settled task");
975
+ }
976
+ if (!existingResult && terminal.attemptId !== run.attemptIds[terminal.nodeId]) {
977
+ throw new Error("terminal recovery task fact references a superseded attempt");
978
+ }
979
+ }
980
+ }
981
+ for (const task of run.definition.tasks) {
982
+ if (isTerminalTask(run.taskStatus[task.id]) || run.taskResults[task.id]) continue;
983
+ if (!terminalNodes.has(task.id) && !base.blockedNodeIds.includes(task.id)) {
984
+ throw new Error(`terminal recovery omits unresolved task ${task.id}`);
985
+ }
986
+ }
987
+ const synthesisUnresolved = run.definition.synthesis !== undefined && !run.synthesisResult;
988
+ if (synthesisUnresolved && !terminalNodes.has("__synthesis__")) {
989
+ throw new Error("terminal recovery omits unresolved synthesis");
990
+ }
991
+ return { ...base, timestamp };
992
+ }
993
+
994
+ function parseDuplicateTerminalRecovery(raw: Record<string, unknown>, run: WorkflowRun): TerminalRecoveryEvent {
995
+ const base = parseTerminalRecoveryBase(raw, run.runId, taskNodeIds(run), "terminal recovery duplicate");
996
+ return { ...base, timestamp: validTimestamp(raw.timestamp, "timestamp") };
997
+ }
998
+
999
+ function parseAttemptRecovery(raw: Record<string, unknown>, run: WorkflowRun, timestamp: number): AttemptRecoveryEvent {
1000
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION) throw new Error("attempt recovery schema version is unsupported");
1001
+ assertKeys(
1002
+ raw,
1003
+ ["kind", "schemaVersion", "runId", "nodeId", "branchGeneration", "rotation", "legacyMigration", "timestamp"],
1004
+ "attempt recovery",
1005
+ );
1006
+ const legacyMigration = raw.legacyMigration === true;
1007
+ if (
1008
+ run.terminalIntent !== undefined ||
1009
+ ["completed", "failed", "stopped"].includes(run.status) ||
1010
+ (run.status === "interrupted" && !legacyMigration)
1011
+ ) {
1012
+ throw new Error(`invalid attempt recovery source state: ${run.status}`);
1013
+ }
1014
+ if (raw.legacyMigration !== undefined && raw.legacyMigration !== true) {
1015
+ throw new Error("attempt recovery legacy migration marker is invalid");
1016
+ }
1017
+ const branchGeneration = parseBranchGeneration(raw.branchGeneration, "branchGeneration");
1018
+ const nodeId = boundedString(raw.nodeId, "nodeId", JOURNAL_ID_LIMIT);
1019
+ const rotation = parseRecoveryRotation(raw.rotation, run, "rotation", legacyMigration);
1020
+ if (rotation.nodeId !== nodeId) throw new Error("attempt recovery node does not match its rotation");
1021
+ return {
1022
+ kind: "attempt_recovery",
1023
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1024
+ runId: run.runId,
1025
+ nodeId,
1026
+ branchGeneration,
1027
+ rotation,
1028
+ ...(legacyMigration ? { legacyMigration: true } : {}),
1029
+ timestamp,
1030
+ };
1031
+ }
1032
+
1033
+ function defaultAttemptId(runId: string, nodeId: string): string {
1034
+ return `${runId}/${nodeId}/attempt-1`;
1035
+ }
1036
+
1037
+ function parseEvent(raw: unknown, runs: Map<string, WorkflowRun>): JournalEvent {
1038
+ if (!isRecord(raw)) throw new Error("journal event must be an object");
1039
+ assertKeys(
1040
+ raw,
1041
+ [
1042
+ "kind",
1043
+ "schemaVersion",
1044
+ "runId",
1045
+ "definition",
1046
+ "attempts",
1047
+ "attemptIds",
1048
+ "status",
1049
+ "error",
1050
+ "terminalIntent",
1051
+ "nodeId",
1052
+ "agentId",
1053
+ "attemptId",
1054
+ "generation",
1055
+ "owner",
1056
+ "result",
1057
+ "compactionCount",
1058
+ "branchGeneration",
1059
+ "rotations",
1060
+ "rotation",
1061
+ "sourceAttemptId",
1062
+ "sourceGeneration",
1063
+ "sourceStatus",
1064
+ "supersededAgentId",
1065
+ "legacyMigration",
1066
+ "terminalResults",
1067
+ "blockedNodeIds",
1068
+ "recoveryId",
1069
+ "timestamp",
1070
+ ],
1071
+ "journal event",
1072
+ );
1073
+ if (typeof raw.kind !== "string" || !JOURNAL_KINDS.has(raw.kind)) throw new Error("journal event kind is unknown");
1074
+ if (raw.schemaVersion !== JOURNAL_SCHEMA_VERSION && raw.schemaVersion !== JOURNAL_LEGACY_SCHEMA_VERSION) {
1075
+ throw new Error("journal schema version is unsupported");
1076
+ }
1077
+ const runId = boundedString(raw.runId, "runId", JOURNAL_ID_LIMIT);
1078
+ const timestamp = validTimestamp(raw.timestamp, "timestamp");
1079
+
1080
+ if (raw.kind === "run_created") {
1081
+ if (runs.has(runId)) throw new Error("duplicate workflow run_created event");
1082
+ const definition = validateWorkflow(raw.definition);
1083
+ return {
1084
+ kind: "run_created",
1085
+ schemaVersion: raw.schemaVersion as JournalSchemaVersion,
1086
+ runId,
1087
+ definition,
1088
+ timestamp,
1089
+ ...(raw.attempts === undefined ? {} : { attempts: parseAttemptMap(raw.attempts, definition, "attempts") }),
1090
+ ...(raw.attemptIds === undefined
1091
+ ? {}
1092
+ : { attemptIds: parseAttemptIdMap(raw.attemptIds, definition, "attemptIds") }),
1093
+ };
1094
+ }
1095
+
1096
+ const run = runs.get(runId);
1097
+ if (!run) throw new Error("journal event references an unknown run");
1098
+ const nodes = taskNodeIds(run);
1099
+ if (raw.kind === "run_recovery") return parseRunRecovery(raw, run, timestamp);
1100
+ if (raw.kind === "terminal_recovery") return parseTerminalRecovery(raw, run, timestamp);
1101
+ if (raw.kind === "attempt_recovery") return parseAttemptRecovery(raw, run, timestamp);
1102
+ if (raw.kind === "workflow_transition") {
1103
+ if (!WORKFLOW_STATUS_VALUES.has(raw.status as WorkflowStatus)) throw new Error("workflow status is unknown");
1104
+ if (!canTransitionWorkflow(run.status, raw.status as WorkflowStatus)) {
1105
+ throw new Error(`invalid workflow transition: ${run.status} -> ${String(raw.status)}`);
1106
+ }
1107
+ if (raw.terminalIntent !== undefined && raw.terminalIntent !== "stop" && raw.terminalIntent !== "failure") {
1108
+ throw new Error("workflow terminal intent is invalid");
1109
+ }
1110
+ return {
1111
+ kind: "workflow_transition",
1112
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1113
+ runId,
1114
+ status: raw.status as WorkflowStatus,
1115
+ timestamp,
1116
+ ...(raw.error === undefined ? {} : { error: boundedString(raw.error, "error", JOURNAL_ERROR_LIMIT) }),
1117
+ ...(raw.terminalIntent === undefined ? {} : { terminalIntent: raw.terminalIntent }),
1118
+ };
1119
+ }
1120
+
1121
+ // Terminal workflow outcomes are immutable. A late task/synthesis fact from
1122
+ // the superseded execution is stale, not a reason to quarantine an otherwise
1123
+ // valid run (duplicate workflow transition entries remain replay-compatible).
1124
+ if (isTerminalWorkflowStatus(run.status) && raw.kind !== "workflow_transition") {
1125
+ throw new StaleJournalGenerationError("journal event follows a terminal workflow outcome");
1126
+ }
1127
+
1128
+ if (raw.kind === "synthesis_result") {
1129
+ const result = parseResult(raw.result, "synthesis result");
1130
+ const rawAttemptId =
1131
+ raw.attemptId === undefined ? undefined : boundedString(raw.attemptId, "attemptId", JOURNAL_ID_LIMIT);
1132
+ if (rawAttemptId !== undefined && result.attemptId !== undefined && rawAttemptId !== result.attemptId) {
1133
+ throw new StaleJournalGenerationError("synthesis result carries conflicting attempt generations");
1134
+ }
1135
+ const synthesisAttemptId = rawAttemptId ?? result.attemptId ?? run.attemptIds.__synthesis__;
1136
+ const owner = raw.owner === undefined ? undefined : parseOwner(raw.owner, runId, "__synthesis__");
1137
+ const currentAttemptId = run.attemptIds.__synthesis__;
1138
+ if (
1139
+ run.attemptTracking &&
1140
+ (rawAttemptId === undefined ||
1141
+ result.attemptId === undefined ||
1142
+ owner === undefined ||
1143
+ owner.attemptId === undefined)
1144
+ ) {
1145
+ throw new Error("synthesis result is missing a required raw, payload, or owner attemptId");
1146
+ }
1147
+ if (
1148
+ run.attemptTracking &&
1149
+ (rawAttemptId !== result.attemptId ||
1150
+ synthesisAttemptId !== currentAttemptId ||
1151
+ owner?.attemptId !== currentAttemptId)
1152
+ ) {
1153
+ throw new StaleJournalGenerationError("synthesis result references a stale attempt");
1154
+ }
1155
+ if (synthesisAttemptId !== currentAttemptId) {
1156
+ throw new StaleJournalGenerationError("synthesis result references a superseded attempt");
1157
+ }
1158
+ if (owner?.attemptId && currentAttemptId && owner.attemptId !== currentAttemptId) {
1159
+ throw new StaleJournalGenerationError("journal synthesis owner references a superseded attempt");
1160
+ }
1161
+ return {
1162
+ kind: "synthesis_result",
1163
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1164
+ runId,
1165
+ result: result.attemptId === undefined ? { ...result, attemptId: synthesisAttemptId } : result,
1166
+ attemptId: synthesisAttemptId,
1167
+ timestamp,
1168
+ ...(owner ? { owner } : {}),
1169
+ };
1170
+ }
1171
+
1172
+ const nodeId = boundedString(raw.nodeId, "nodeId", JOURNAL_ID_LIMIT);
1173
+ if (
1174
+ !nodes.has(nodeId) ||
1175
+ (nodeId === "__synthesis__" && raw.kind !== "task_compacted" && raw.kind !== "task_attempt")
1176
+ )
1177
+ throw new Error("journal event references an unknown task node");
1178
+ const owner = raw.owner === undefined ? undefined : parseOwner(raw.owner, runId, nodeId);
1179
+ const rawAttemptId =
1180
+ raw.attemptId === undefined ? undefined : boundedString(raw.attemptId, "attemptId", JOURNAL_ID_LIMIT);
1181
+ const attemptId = rawAttemptId ?? run.attemptIds[nodeId];
1182
+ if (run.attemptTracking && (rawAttemptId === undefined || owner === undefined || owner.attemptId === undefined)) {
1183
+ throw new Error("journal attempt-aware event is missing a required raw or owner attemptId");
1184
+ }
1185
+ if (raw.kind !== "task_attempt" && owner?.attemptId && attemptId && owner.attemptId !== attemptId) {
1186
+ throw new StaleJournalGenerationError("journal owner references a different attempt");
1187
+ }
1188
+ if (raw.kind !== "task_attempt" && attemptId !== run.attemptIds[nodeId]) {
1189
+ throw new StaleJournalGenerationError("journal event references a superseded attempt");
1190
+ }
1191
+
1192
+ if (raw.kind === "task_attempt") {
1193
+ const generation = raw.generation;
1194
+ if (typeof generation !== "number" || !Number.isInteger(generation) || generation < 1 || generation > 1_000_000) {
1195
+ throw new Error("task attempt generation is invalid");
1196
+ }
1197
+ if (generation <= (run.attempts[nodeId] ?? 0)) {
1198
+ throw new StaleJournalGenerationError("task attempt generation is stale");
1199
+ }
1200
+ const expectedAttemptId = `${runId}/${nodeId}/attempt-${generation}`;
1201
+ if (attemptId !== expectedAttemptId || owner?.attemptId !== attemptId) {
1202
+ throw new Error("task attempt does not identify its next generation");
1203
+ }
1204
+ return {
1205
+ kind: "task_attempt",
1206
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1207
+ runId,
1208
+ nodeId,
1209
+ attemptId,
1210
+ generation,
1211
+ timestamp,
1212
+ ...(owner ? { owner } : {}),
1213
+ };
1214
+ }
1215
+
1216
+ if (raw.kind === "task_transition") {
1217
+ if (!TASK_STATUS_VALUES.has(raw.status as TaskStatus)) throw new Error("task status is unknown");
1218
+ const previous = run.taskStatus[nodeId];
1219
+ const legacyDirectStart = previous === "pending" && raw.status === "running";
1220
+ if (!previous || (!canTransitionTask(previous, raw.status as TaskStatus) && !legacyDirectStart)) {
1221
+ throw new Error(`invalid task transition: ${String(previous)} -> ${String(raw.status)}`);
1222
+ }
1223
+ const agentId = raw.agentId === undefined ? undefined : boundedString(raw.agentId, "agentId", JOURNAL_ID_LIMIT);
1224
+ return {
1225
+ kind: "task_transition",
1226
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1227
+ runId,
1228
+ nodeId,
1229
+ status: raw.status as TaskStatus,
1230
+ timestamp,
1231
+ ...(agentId ? { agentId } : {}),
1232
+ ...(attemptId ? { attemptId } : {}),
1233
+ ...(owner ? { owner } : {}),
1234
+ };
1235
+ }
1236
+
1237
+ if (raw.kind === "task_compacted") {
1238
+ if (
1239
+ typeof raw.compactionCount !== "number" ||
1240
+ !Number.isInteger(raw.compactionCount) ||
1241
+ raw.compactionCount < 0 ||
1242
+ raw.compactionCount > 1_000_000
1243
+ ) {
1244
+ throw new Error("compaction count is invalid");
1245
+ }
1246
+ return {
1247
+ kind: "task_compacted",
1248
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1249
+ runId,
1250
+ nodeId,
1251
+ compactionCount: raw.compactionCount,
1252
+ attemptId,
1253
+ timestamp,
1254
+ ...(owner ? { owner } : {}),
1255
+ };
1256
+ }
1257
+
1258
+ const result = parseResult(raw.result, "task result");
1259
+ if (run.attemptTracking && result.attemptId === undefined) {
1260
+ throw new Error("task result is missing a required payload attemptId");
1261
+ }
1262
+ if (result.attemptId && attemptId && result.attemptId !== attemptId) {
1263
+ throw new StaleJournalGenerationError("task result payload references a superseded attempt");
1264
+ }
1265
+ return {
1266
+ kind: "task_result",
1267
+ schemaVersion: JOURNAL_SCHEMA_VERSION,
1268
+ runId,
1269
+ nodeId,
1270
+ result: result.attemptId === undefined && attemptId ? { ...result, attemptId } : result,
1271
+ attemptId,
1272
+ timestamp,
1273
+ ...(owner ? { owner } : {}),
1274
+ };
1275
+ }
1276
+
1277
+ export function boundedText(value: unknown, limit = JOURNAL_TEXT_LIMIT): { text?: string; truncated?: boolean } {
1278
+ if (typeof value !== "string" || value.length === 0) return {};
1279
+ if (value.length <= limit) return { text: value };
1280
+ return { text: `${value.slice(0, limit)}\n…[truncated]`, truncated: true };
1281
+ }
1282
+
1283
+ export function boundedError(value: unknown): string | undefined {
1284
+ const result = boundedText(value instanceof Error ? value.message : value, JOURNAL_ERROR_LIMIT);
1285
+ return result.text;
1286
+ }
1287
+
1288
+ export function replayJournal(
1289
+ entries: readonly SessionEntryLike[],
1290
+ options: ReplayJournalOptions = {},
1291
+ ): Map<string, WorkflowRun> {
1292
+ const runs = new Map<string, WorkflowRun>();
1293
+ const quarantined = new Set<string>();
1294
+ /** Recovery identities are scoped to one replayed run and bounded explicitly. */
1295
+ const seenRecoveries = new Map<string, Map<string, string>>();
1296
+ let reported = false;
1297
+ const report = (message: string): void => {
1298
+ if (reported) return;
1299
+ reported = true;
1300
+ options.onInvalid?.(`quarantined malformed workflow journal run: ${message}`.slice(0, 2_000));
1301
+ };
1302
+ for (const entry of entries) {
1303
+ if (entry.type !== "custom" || entry.customType !== JOURNAL_ENTRY_TYPE) continue;
1304
+ const raw = entry.data;
1305
+ const rawRunId = isRecord(raw) && typeof raw.runId === "string" ? raw.runId : undefined;
1306
+ if (rawRunId && quarantined.has(rawRunId)) continue;
1307
+ try {
1308
+ if (
1309
+ isRecord(raw) &&
1310
+ (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") &&
1311
+ typeof raw.recoveryId === "string"
1312
+ ) {
1313
+ for (const [seenRunId, seen] of seenRecoveries) {
1314
+ if (seenRunId === rawRunId || !seen.has(raw.recoveryId)) continue;
1315
+ // A colliding recovery ID belongs to the malformed current entry; keep the previously validated owner intact.
1316
+ throw new Error(`recoveryId ${raw.recoveryId} is already owned by workflow run ${seenRunId}`);
1317
+ }
1318
+ }
1319
+ if (isRecord(raw) && (raw.kind === "run_recovery" || raw.kind === "terminal_recovery") && rawRunId) {
1320
+ const run = runs.get(rawRunId);
1321
+ const seen = seenRecoveries.get(rawRunId);
1322
+ if (run && seen) {
1323
+ // Parse duplicates without consulting current generation/status: the
1324
+ // first copy already advanced those fields. The normalized semantic
1325
+ // digest still validates every bounded field before accepting a no-op.
1326
+ const candidate =
1327
+ raw.kind === "run_recovery"
1328
+ ? parseDuplicateRunRecovery(raw, run)
1329
+ : parseDuplicateTerminalRecovery(raw, run);
1330
+ const previousFingerprint = seen.get(candidate.recoveryId);
1331
+ if (previousFingerprint !== undefined) {
1332
+ const candidateFingerprint = recoverySemanticFingerprint(candidate);
1333
+ if (candidateFingerprint !== previousFingerprint) {
1334
+ throw new Error(`conflicting duplicate recoveryId ${candidate.recoveryId}`);
1335
+ }
1336
+ continue;
1337
+ }
1338
+ }
1339
+ }
1340
+
1341
+ const event = parseEvent(raw, runs);
1342
+ if (quarantined.has(event.runId)) continue;
1343
+ if (event.kind === "run_created") {
1344
+ applyJournalEvent(runs, event);
1345
+ seenRecoveries.set(event.runId, new Map());
1346
+ continue;
1347
+ }
1348
+ if (event.kind === "run_recovery" || event.kind === "terminal_recovery") {
1349
+ const seen = seenRecoveries.get(event.runId) ?? new Map<string, string>();
1350
+ const previousFingerprint = seen.get(event.recoveryId);
1351
+ if (previousFingerprint !== undefined) {
1352
+ const eventFingerprint = recoverySemanticFingerprint(event);
1353
+ if (eventFingerprint !== previousFingerprint) {
1354
+ throw new Error(`conflicting duplicate recoveryId ${event.recoveryId}`);
1355
+ }
1356
+ continue;
1357
+ }
1358
+ if (seen.size >= JOURNAL_RECOVERY_SEEN_LIMIT) {
1359
+ throw new Error(`workflow recovery history exceeds ${JOURNAL_RECOVERY_SEEN_LIMIT} entries`);
1360
+ }
1361
+ applyJournalEvent(runs, event);
1362
+ seen.set(event.recoveryId, recoverySemanticFingerprint(event));
1363
+ seenRecoveries.set(event.runId, seen);
1364
+ continue;
1365
+ }
1366
+ applyJournalEvent(runs, event);
1367
+ } catch (error: unknown) {
1368
+ if (error instanceof StaleJournalGenerationError) continue;
1369
+ if (rawRunId) {
1370
+ quarantined.add(rawRunId);
1371
+ runs.delete(rawRunId);
1372
+ seenRecoveries.delete(rawRunId);
1373
+ }
1374
+ report(error instanceof Error ? error.message : String(error));
1375
+ }
1376
+ }
1377
+ for (const [runId, run] of runs) {
1378
+ try {
1379
+ validateAggregate(run);
1380
+ } catch (error: unknown) {
1381
+ runs.delete(runId);
1382
+ quarantined.add(runId);
1383
+ seenRecoveries.delete(runId);
1384
+ report(`${runId}: ${error instanceof Error ? error.message : String(error)}`);
1385
+ }
1386
+ }
1387
+ return runs;
1388
+ }
1389
+
1390
+ function applyRecoveryRotation(run: WorkflowRun, rotation: RecoveryRotation): void {
1391
+ run.attemptTracking = true;
1392
+ run.attempts[rotation.nodeId] = rotation.generation;
1393
+ run.attemptIds[rotation.nodeId] = rotation.attemptId;
1394
+ delete run.agentIds[rotation.nodeId];
1395
+ delete run.taskResults[rotation.nodeId];
1396
+ run.compactions[rotation.nodeId] = 0;
1397
+ if (rotation.nodeId === "__synthesis__") {
1398
+ run.synthesisAgentId = undefined;
1399
+ run.synthesisResult = undefined;
1400
+ } else {
1401
+ run.taskStatus[rotation.nodeId] = "ready";
1402
+ }
1403
+ }
1404
+
1405
+ function applyRecoveryTerminalResult(run: WorkflowRun, terminal: RecoveryTerminalResult): void {
1406
+ run.compactions[terminal.nodeId] = Math.max(run.compactions[terminal.nodeId] ?? 0, terminal.result.compactionCount);
1407
+ if (terminal.nodeId === "__synthesis__") {
1408
+ run.synthesisResult = terminal.result;
1409
+ if (terminal.result.agentId) run.synthesisAgentId = terminal.result.agentId;
1410
+ } else {
1411
+ run.taskResults[terminal.nodeId] = terminal.result;
1412
+ run.taskStatus[terminal.nodeId] = terminal.result.status;
1413
+ if (terminal.result.agentId) run.agentIds[terminal.nodeId] = terminal.result.agentId;
1414
+ }
1415
+ }
1416
+
1417
+ export function applyRecoveryTerminalResults(
1418
+ run: WorkflowRun,
1419
+ terminalResults: readonly RecoveryTerminalResult[],
1420
+ ): void {
1421
+ for (const terminal of terminalResults) applyRecoveryTerminalResult(run, terminal);
1422
+ }
1423
+
1424
+ /** Apply an already validated atomic recovery event after its append succeeds. */
1425
+ export function applyRecoveryEvent(run: WorkflowRun, event: RunRecoveryEvent | AttemptRecoveryEvent): void {
1426
+ if (event.kind === "run_recovery") {
1427
+ run.status = "interrupted";
1428
+ run.terminalIntent = undefined;
1429
+ for (const rotation of event.rotations) applyRecoveryRotation(run, rotation);
1430
+ applyRecoveryTerminalResults(run, event.terminalResults ?? []);
1431
+ } else {
1432
+ applyRecoveryRotation(run, event.rotation);
1433
+ }
1434
+ run.updatedAt = event.timestamp;
1435
+ }
1436
+ /** Apply a validated terminal recovery after its durable append succeeds. */
1437
+ export function applyTerminalRecoveryEvent(run: WorkflowRun, event: TerminalRecoveryEvent): void {
1438
+ for (const terminal of event.terminalResults) applyRecoveryTerminalResult(run, terminal);
1439
+ for (const nodeId of event.blockedNodeIds) {
1440
+ if (!isTerminalTask(run.taskStatus[nodeId])) run.taskStatus[nodeId] = "blocked";
1441
+ }
1442
+ run.status = event.status;
1443
+ run.terminalIntent = event.terminalIntent;
1444
+ if (event.error !== undefined) run.error = event.error;
1445
+ if (event.status === "stopped") run.nonResumable = true;
1446
+ run.updatedAt = event.timestamp;
1447
+ }
1448
+
1449
+ function applyJournalEvent(runs: Map<string, WorkflowRun>, event: JournalEvent): void {
1450
+ if (event.kind === "run_created") {
1451
+ const taskStatus: Record<string, TaskStatus> = {};
1452
+ const attempts: Record<string, number> = {};
1453
+ const attemptIds: Record<string, string> = {};
1454
+ for (const task of event.definition.tasks) {
1455
+ taskStatus[task.id] = "pending";
1456
+ attempts[task.id] = event.attempts?.[task.id] ?? 1;
1457
+ attemptIds[task.id] = event.attemptIds?.[task.id] ?? defaultAttemptId(event.runId, task.id);
1458
+ }
1459
+ attempts.__synthesis__ = event.attempts?.__synthesis__ ?? 1;
1460
+ attemptIds.__synthesis__ = event.attemptIds?.__synthesis__ ?? defaultAttemptId(event.runId, "__synthesis__");
1461
+ runs.set(event.runId, {
1462
+ runId: event.runId,
1463
+ schemaVersion: event.schemaVersion,
1464
+ definition: event.definition,
1465
+ status: "pending",
1466
+ taskStatus,
1467
+ agentIds: {},
1468
+ attempts,
1469
+ attemptIds,
1470
+ attemptTracking: event.attemptIds !== undefined,
1471
+ taskResults: {},
1472
+ compactions: {},
1473
+ startedAt: event.timestamp,
1474
+ updatedAt: event.timestamp,
1475
+ });
1476
+ return;
1477
+ }
1478
+
1479
+ const run = runs.get(event.runId);
1480
+ if (!run) return;
1481
+ run.updatedAt = event.timestamp;
1482
+ switch (event.kind) {
1483
+ case "workflow_transition":
1484
+ run.status = event.status;
1485
+ if (event.error !== undefined) run.error = event.error;
1486
+ if (event.terminalIntent !== undefined) run.terminalIntent = event.terminalIntent;
1487
+ break;
1488
+ case "run_recovery":
1489
+ case "attempt_recovery":
1490
+ applyRecoveryEvent(run, event);
1491
+ break;
1492
+ case "terminal_recovery":
1493
+ applyTerminalRecoveryEvent(run, event);
1494
+ break;
1495
+ case "task_attempt":
1496
+ run.attemptTracking = true;
1497
+ run.attempts[event.nodeId] = event.generation;
1498
+ run.attemptIds[event.nodeId] = event.attemptId;
1499
+ delete run.agentIds[event.nodeId];
1500
+ delete run.taskResults[event.nodeId];
1501
+ if (event.nodeId === "__synthesis__") {
1502
+ run.synthesisAgentId = undefined;
1503
+ run.synthesisResult = undefined;
1504
+ } else {
1505
+ run.taskStatus[event.nodeId] = "ready";
1506
+ }
1507
+ run.compactions[event.nodeId] = 0;
1508
+ break;
1509
+ case "task_transition":
1510
+ if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1511
+ throw new Error("task transition references a superseded attempt");
1512
+ }
1513
+ run.taskStatus[event.nodeId] = event.status;
1514
+ if (event.agentId) run.agentIds[event.nodeId] = event.agentId;
1515
+ break;
1516
+ case "task_result":
1517
+ if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1518
+ throw new Error("task result references a superseded attempt");
1519
+ }
1520
+ if (
1521
+ run.taskResults[event.nodeId] &&
1522
+ JSON.stringify(run.taskResults[event.nodeId]) !== JSON.stringify(event.result)
1523
+ ) {
1524
+ throw new Error("contradictory terminal task result");
1525
+ }
1526
+ run.taskResults[event.nodeId] = event.result;
1527
+ run.taskStatus[event.nodeId] = event.result.status;
1528
+ run.compactions[event.nodeId] = Math.max(run.compactions[event.nodeId] ?? 0, event.result.compactionCount);
1529
+ if (event.result.agentId) run.agentIds[event.nodeId] = event.result.agentId;
1530
+ break;
1531
+ case "task_compacted":
1532
+ if (event.attemptId && event.attemptId !== run.attemptIds[event.nodeId]) {
1533
+ throw new Error("compaction references a superseded attempt");
1534
+ }
1535
+ run.compactions[event.nodeId] = event.compactionCount;
1536
+ break;
1537
+ case "synthesis_result":
1538
+ if (run.synthesisResult && JSON.stringify(run.synthesisResult) !== JSON.stringify(event.result)) {
1539
+ throw new Error("contradictory synthesis result");
1540
+ }
1541
+ run.synthesisResult = event.result;
1542
+ run.compactions.__synthesis__ = Math.max(run.compactions.__synthesis__ ?? 0, event.result.compactionCount);
1543
+ if (event.result.agentId) run.synthesisAgentId = event.result.agentId;
1544
+ break;
1545
+ }
1546
+ }
1547
+
1548
+ function validateAggregate(run: WorkflowRun): void {
1549
+ const nodes = [...run.definition.tasks.map((task) => task.id), "__synthesis__"];
1550
+ for (const nodeId of nodes) {
1551
+ if (!Number.isInteger(run.attempts[nodeId]) || run.attempts[nodeId] < 1) {
1552
+ throw new Error(`missing attempt generation for ${nodeId}`);
1553
+ }
1554
+ boundedString(run.attemptIds[nodeId], `attemptIds.${nodeId}`, JOURNAL_ID_LIMIT);
1555
+ }
1556
+ if (isTerminalWorkflowStatus(run.status)) {
1557
+ const statuses = Object.values(run.taskStatus);
1558
+ if (run.status === "completed" && statuses.some((status) => status !== "completed")) {
1559
+ throw new Error("completed workflow has non-completed tasks");
1560
+ }
1561
+ if (run.status === "stopped" && statuses.some((status) => !isTerminalTask(status))) {
1562
+ throw new Error("stopped workflow has active tasks");
1563
+ }
1564
+ if (run.status === "failed" && statuses.some((status) => !isTerminalTask(status))) {
1565
+ throw new Error("failed workflow has active tasks");
1566
+ }
1567
+ if (run.status === "failed" || run.status === "stopped") {
1568
+ if (run.synthesisAgentId && !run.synthesisResult) {
1569
+ throw new Error(`${run.status} workflow has an active synthesis agent`);
1570
+ }
1571
+ }
1572
+ }
1573
+ if (run.synthesisResult && Object.values(run.taskStatus).some((status) => !isTerminalTask(status))) {
1574
+ throw new Error("synthesis settled before workflow tasks");
1575
+ }
1576
+ }
1577
+
1578
+ function isTerminalWorkflowStatus(status: WorkflowStatus): boolean {
1579
+ return status === "completed" || status === "failed" || status === "stopped";
1580
+ }
1581
+
1582
+ export function snapshotRun(run: WorkflowRun): WorkflowRun {
1583
+ return JSON.parse(JSON.stringify(run)) as WorkflowRun;
1584
+ }