ai-workflow-logic 0.1.5 → 0.1.7

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.
@@ -1,20 +1,24 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import type { AIWorkflowFlowRunCheckpoint, FlowClosedValue } from 'ai-workflow-contract';
5
- import type {
6
- DefinitionStepDigest,
7
- ImmutableStepRecord,
8
- StepMerkleNode,
9
- StepMerkleTree,
10
- } from 'flow-step-space-contract';
11
- import {
12
- buildStepMerkleTree,
13
- createImmutableStepRecord,
14
- diffStepMerkleTrees,
15
- } from 'flow-step-space-logic';
4
+ import type { AIWorkflowFlowRunCheckpoint, FlowClosedObject, FlowClosedValue } from 'ai-workflow-contract';
5
+ import type { DefinitionStepDigest, ImmutableStepRecord, StepMerkleNode, StepMerkleTree } from 'flow-step-space-contract';
6
+ import { buildStepMerkleTree, createImmutableStepRecord, diffStepMerkleTrees } from 'flow-step-space-logic';
16
7
  import { normalizeFlowRunCheckpoint } from './run-checkpoint.js';
17
8
 
9
+ export type CheckpointDurabilityRole = 'record' | 'tree' | 'receipt' | 'head';
10
+ export type CheckpointDurabilityOperation = 'directory-create' | 'file-fsync' | 'rename' | 'directory-fsync';
11
+ export interface CheckpointDurabilityEvent {
12
+ readonly operation: CheckpointDurabilityOperation;
13
+ readonly role: CheckpointDurabilityRole;
14
+ readonly path: string;
15
+ readonly targetPath?: string;
16
+ }
17
+ export interface CheckpointDurabilityRuntime {
18
+ beforeEvent?(event: CheckpointDurabilityEvent): void;
19
+ onEvent?(event: CheckpointDurabilityEvent): void;
20
+ }
21
+
18
22
  export interface FlowRunCheckpointHead {
19
23
  readonly schemaVersion: 'depa.flow-run-checkpoint-head/v1';
20
24
  readonly instanceId: string;
@@ -22,398 +26,522 @@ export interface FlowRunCheckpointHead {
22
26
  readonly definition: AIWorkflowFlowRunCheckpoint['definition'];
23
27
  readonly version: number;
24
28
  readonly logicalDigest: DefinitionStepDigest;
29
+ readonly control: FlowClosedObject;
25
30
  readonly runStepStateRootDigest: DefinitionStepDigest;
26
31
  }
27
-
28
32
  export interface FlowRunCheckpointSidecarReceipt {
29
33
  readonly schemaVersion: 'depa.flow-run-checkpoint-sidecar-receipt/v1';
30
34
  readonly version: number;
31
35
  readonly previousRootDigest: DefinitionStepDigest | null;
32
36
  readonly rootDigest: DefinitionStepDigest;
33
37
  readonly logicalDigest: DefinitionStepDigest;
38
+ readonly controlRecordDigests: readonly DefinitionStepDigest[];
39
+ readonly addedMaterialRecordDigests: readonly DefinitionStepDigest[];
34
40
  readonly addedRecordDigests: readonly DefinitionStepDigest[];
35
41
  readonly addedTreeNodeDigests: readonly DefinitionStepDigest[];
36
42
  }
37
-
43
+ export interface CheckpointSidecarEncodeConfig {
44
+ readonly definitionStepIds?: readonly string[];
45
+ readonly previousMaterialRecordDigests?: readonly DefinitionStepDigest[];
46
+ }
38
47
  type EncodedCheckpoint = {
39
48
  readonly head: FlowRunCheckpointHead;
49
+ readonly controlRecords: readonly ImmutableStepRecord[];
40
50
  readonly records: readonly ImmutableStepRecord[];
51
+ readonly materialRecords: readonly ImmutableStepRecord[];
41
52
  readonly tree: StepMerkleTree;
42
53
  readonly receipt: FlowRunCheckpointSidecarReceipt;
43
54
  };
44
55
 
45
- type Marker =
46
- | { readonly kind: 'object' }
47
- | { readonly kind: 'array'; readonly length: number }
48
- | { readonly kind: 'value'; readonly value: FlowClosedValue };
49
-
50
56
  const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
51
-
52
- function compareCodeUnits(left: string, right: string): number {
53
- return left < right ? -1 : left > right ? 1 : 0;
54
- }
55
-
57
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
56
58
  function canonicalJson(value: unknown): string {
57
59
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
58
60
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
59
61
  const record = value as Readonly<Record<string, unknown>>;
60
- return `{${Object.keys(record).sort(compareCodeUnits).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
61
- }
62
-
63
- function digest(value: unknown): DefinitionStepDigest {
64
- return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
62
+ return `{${Object.keys(record).sort(compare).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
65
63
  }
66
-
64
+ const digest = (value: unknown): DefinitionStepDigest => `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
67
65
  function digestFileName(value: DefinitionStepDigest): string {
68
66
  if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error(`Invalid sidecar digest ${value}`);
69
- return `${value.slice('sha256:'.length)}.json`;
67
+ return `${value.slice(7)}.json`;
68
+ }
69
+ function object(value: unknown, label: string): FlowClosedObject {
70
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be a closed object.`);
71
+ return value as FlowClosedObject;
70
72
  }
73
+ const entries = (value: unknown, label: string): readonly [string, FlowClosedValue][] => {
74
+ const record = object(value, label);
75
+ return Object.keys(record).sort(compare).map((key) => [key, record[key]] as const);
76
+ };
71
77
 
72
- function flatten(value: FlowClosedValue): ImmutableStepRecord[] {
73
- const records: ImmutableStepRecord[] = [];
74
- const visit = (current: FlowClosedValue, segments: readonly string[]): void => {
75
- const key = JSON.stringify(segments);
76
- if (Array.isArray(current)) {
77
- records.push(createImmutableStepRecord(key, { kind: 'array', length: current.length }));
78
- current.forEach((item, index) => visit(item, [...segments, String(index)]));
79
- return;
80
- }
81
- if (current !== null && typeof current === 'object') {
82
- records.push(createImmutableStepRecord(key, { kind: 'object' }));
83
- const object = current as Readonly<Record<string, FlowClosedValue>>;
84
- Object.keys(object).sort(compareCodeUnits).forEach((name) => visit(object[name], [...segments, name]));
85
- return;
86
- }
87
- records.push(createImmutableStepRecord(key, { kind: 'value', value: current }));
78
+ function envelope(checkpoint: AIWorkflowFlowRunCheckpoint): FlowClosedObject {
79
+ return {
80
+ schemaVersion: checkpoint.schemaVersion,
81
+ input: checkpoint.input,
82
+ config: checkpoint.config,
83
+ state: checkpoint.state,
84
+ output: checkpoint.output,
88
85
  };
89
- visit(value, []);
90
- return records;
91
86
  }
92
87
 
93
- function parseMarker(record: ImmutableStepRecord): Marker {
94
- const value = record.value;
95
- if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('Sidecar marker must be a closed object.');
96
- const marker = value as Readonly<Record<string, FlowClosedValue>>;
97
- const keys = Object.keys(marker).sort(compareCodeUnits);
98
- if (marker.kind === 'object' && canonicalJson(keys) === canonicalJson(['kind'])) return { kind: 'object' };
99
- if (marker.kind === 'array' && canonicalJson(keys) === canonicalJson(['kind', 'length'])
100
- && typeof marker.length === 'number' && Number.isSafeInteger(marker.length) && marker.length >= 0) {
101
- return { kind: 'array', length: marker.length };
88
+ /** Membership = frozen binding plus accepted add/update/remove patch facts. */
89
+ function dataMembership(
90
+ runGraph: FlowClosedObject,
91
+ bindingValue: unknown = runGraph.binding,
92
+ frozenDefinitionStepIds?: readonly string[],
93
+ ): readonly string[] {
94
+ const binding = object(bindingValue, 'data binding');
95
+ const definition = object(binding.definition, 'data definition');
96
+ if (!Array.isArray(definition.declarationOrder)) throw new Error('Data binding declarationOrder is required.');
97
+ if (!frozenDefinitionStepIds || canonicalJson(definition.declarationOrder) !== canonicalJson(frozenDefinitionStepIds)) {
98
+ throw new Error('Data binding membership differs from frozen StepSpace authority.');
102
99
  }
103
- if (marker.kind === 'value' && canonicalJson(keys) === canonicalJson(['kind', 'value'])) {
104
- return { kind: 'value', value: marker.value };
100
+ const members = new Set<string>();
101
+ for (const id of definition.declarationOrder) {
102
+ if (typeof id !== 'string' || !id || members.has(id)) throw new Error('Data binding membership is invalid.');
103
+ members.add(id);
105
104
  }
106
- throw new Error(`Invalid sidecar marker for ${record.key}`);
105
+ if (!Array.isArray(runGraph.patchHistory)) throw new Error('Data patchHistory is required.');
106
+ for (const patchValue of runGraph.patchHistory) {
107
+ const patch = object(patchValue, 'data patch');
108
+ if (!Array.isArray(patch.operations)) throw new Error('Data patch operations are required.');
109
+ for (const operationValue of patch.operations) {
110
+ const operation = object(operationValue, 'data patch operation');
111
+ if (operation.op === 'add-node') {
112
+ const node = object(operation.node, 'data add-node');
113
+ if (typeof node.id !== 'string' || !node.id || members.has(node.id)) throw new Error('Data add-node membership is invalid.');
114
+ members.add(node.id);
115
+ } else if (operation.op === 'update-node' || operation.op === 'remove-node') {
116
+ if (typeof operation.nodeId !== 'string' || !members.has(operation.nodeId)) throw new Error(`Data ${String(operation.op)} membership is invalid.`);
117
+ } else throw new Error('Data patch operation is invalid.');
118
+ }
119
+ }
120
+ return [...members].sort(compare);
107
121
  }
108
122
 
109
- function decodePath(key: string): readonly string[] {
110
- const value = JSON.parse(key) as unknown;
111
- if (!Array.isArray(value) || value.some((segment) => typeof segment !== 'string')) {
112
- throw new Error(`Invalid sidecar record key ${key}`);
123
+ const ORCHESTRATION_FIELDS = ['attempts', 'deadlines', 'lanes', 'winners', 'cancellations', 'composites'] as const;
124
+ type OrchestrationField = typeof ORCHESTRATION_FIELDS[number];
125
+
126
+ function runtimeCtrlMembers(orchestrationValue: unknown, definitionStepIds: readonly string[]): readonly string[] {
127
+ const orchestration = object(orchestrationValue, 'ctrl orchestration');
128
+ const members = new Set<string>();
129
+ for (const field of ORCHESTRATION_FIELDS) {
130
+ for (const [identity] of entries(orchestration[field], `ctrl orchestration.${field}`)) {
131
+ if (!definitionOwner(identity, definitionStepIds)) members.add(identity);
132
+ }
113
133
  }
114
- return value;
134
+ return [...members].sort(compare);
115
135
  }
136
+ const ctrlKey = (memberId: string) => `ctrl/${JSON.stringify(memberId)}`;
137
+ const dataKey = (nodeId: string) => `data/${JSON.stringify(nodeId)}`;
138
+ const coreKey = (key: string) => `core/${key}`;
139
+ const extensionKey = (stepId: string, kind: string) => `extension/${JSON.stringify(stepId)}/${JSON.stringify(kind)}`;
116
140
 
117
- function reconstruct(records: readonly ImmutableStepRecord[]): AIWorkflowFlowRunCheckpoint {
118
- const entries = records.map((record) => ({ record, path: decodePath(record.key), marker: parseMarker(record) }))
119
- .sort((left, right) => left.path.length - right.path.length || compareCodeUnits(left.record.key, right.record.key));
120
- if (entries.length === 0 || entries[0].path.length !== 0) throw new Error('Sidecar root record is missing.');
121
- const materialize = (marker: Marker): unknown => marker.kind === 'object'
122
- ? {}
123
- : marker.kind === 'array'
124
- ? new Array(marker.length)
125
- : marker.value;
126
- let root = materialize(entries[0].marker);
127
- for (const entry of entries.slice(1)) {
128
- if (entry.path.length === 0) throw new Error('Sidecar root record is duplicated.');
129
- let parent: unknown = root;
130
- for (const segment of entry.path.slice(0, -1)) {
131
- if (parent === null || typeof parent !== 'object') throw new Error(`Sidecar parent ${segment} is not a container.`);
132
- parent = (parent as Record<string, unknown>)[segment];
141
+ function stepExtensionFacts(checkpoint: AIWorkflowFlowRunCheckpoint, stepId: string): FlowClosedObject {
142
+ return checkpoint.stepExtensions?.byStepId[stepId] as unknown as FlowClosedObject ?? {};
143
+ }
144
+
145
+ function createRunStepLeaf(
146
+ key: string,
147
+ stepId: string | undefined,
148
+ coreValue: FlowClosedValue,
149
+ extensions: FlowClosedObject,
150
+ ) {
151
+ const coreRecord = createImmutableStepRecord(coreKey(key), coreValue);
152
+ const extensionRecords = entries(extensions, `run step extensions ${stepId ?? key}`).map(([kind, fact]) => ({
153
+ kind,
154
+ record: createImmutableStepRecord(extensionKey(stepId ?? key, kind), fact),
155
+ }));
156
+ const leaf = createImmutableStepRecord(key, {
157
+ schemaVersion: 'depa.run-step-state-leaf/v1',
158
+ stepId: stepId ?? null,
159
+ coreRecordDigest: coreRecord.digest,
160
+ extensionRecordDigests: Object.fromEntries(extensionRecords.map(({ kind, record }) => [kind, record.digest])),
161
+ });
162
+ return { leaf, materialRecords: [coreRecord, ...extensionRecords.map(({ record }) => record)] as readonly ImmutableStepRecord[] };
163
+ }
164
+ function definitionOwner(invocationKey: string, definitionStepIds: readonly string[]): string | undefined {
165
+ const visit = invocationKey.lastIndexOf('#');
166
+ const stablePath = visit >= 0 ? invocationKey.slice(0, visit) : invocationKey;
167
+ const id = stablePath.slice(stablePath.lastIndexOf('/') + 1);
168
+ return definitionStepIds.includes(id) ? id : undefined;
169
+ }
170
+
171
+ function partitionCtrl(checkpoint: AIWorkflowFlowRunCheckpoint, idsValue?: readonly string[]) {
172
+ if (!idsValue?.length || idsValue.some((id) => typeof id !== 'string' || !id) || new Set(idsValue).size !== idsValue.length) {
173
+ throw new Error('Ctrl sidecar requires unique frozen definition step identities.');
174
+ }
175
+ if (checkpoint.profile.kind !== 'WorkCtrlFlow' && checkpoint.profile.kind !== 'AICtrlWorkflow') throw new Error('Ctrl profile expected.');
176
+ const definitionStepIds = [...idsValue];
177
+ const snapshot = checkpoint.profile.snapshot as FlowClosedObject;
178
+ const { branchDecisions, nodeResults, waitResults, orchestration, ...snapshotControl } = snapshot;
179
+ const runtimeMembers = runtimeCtrlMembers(orchestration, definitionStepIds);
180
+ const memberIds = [...definitionStepIds.map((id) => `definition:${id}`), ...runtimeMembers.map((id) => `runtime:${id}`)].sort(compare);
181
+ const facts = new Map(memberIds.map((id) => [id, {
182
+ branchDecisions: {},
183
+ nodeResults: {},
184
+ waitResults: {},
185
+ orchestration: Object.fromEntries(ORCHESTRATION_FIELDS.map((field) => [field, {}])),
186
+ }]));
187
+ const runtimeSet = new Set(runtimeMembers);
188
+ const ownerOf = (identity: string) => {
189
+ const owner = definitionOwner(identity, definitionStepIds);
190
+ return owner ? `definition:${owner}` : runtimeSet.has(identity) ? `runtime:${identity}` : undefined;
191
+ };
192
+ const place = (field: 'branchDecisions' | 'nodeResults' | 'waitResults', value: unknown) => {
193
+ for (const [invocationKey, fact] of entries(value, `ctrl ${field}`)) {
194
+ const memberId = ownerOf(invocationKey);
195
+ if (!memberId) throw new Error(`Ctrl ${field} identity ${invocationKey} is not admitted by frozen binding/orchestration.`);
196
+ (facts.get(memberId)![field] as Record<string, FlowClosedValue>)[invocationKey] = fact;
133
197
  }
134
- if (parent === null || typeof parent !== 'object') throw new Error('Sidecar record parent is not a container.');
135
- const segment = entry.path.at(-1)!;
136
- if (Array.isArray(parent)) {
137
- if (!/^(0|[1-9][0-9]*)$/.test(segment) || Number(segment) >= parent.length) throw new Error('Sidecar array index is invalid.');
138
- parent[Number(segment)] = materialize(entry.marker);
139
- } else {
140
- if (Object.prototype.hasOwnProperty.call(parent, segment)) throw new Error('Sidecar object key is duplicated.');
141
- (parent as Record<string, unknown>)[segment] = materialize(entry.marker);
198
+ };
199
+ place('branchDecisions', branchDecisions);
200
+ place('nodeResults', nodeResults);
201
+ place('waitResults', waitResults);
202
+ for (const field of ORCHESTRATION_FIELDS) {
203
+ for (const [identity, fact] of entries(object(orchestration, 'ctrl orchestration')[field], `ctrl orchestration.${field}`)) {
204
+ const memberId = ownerOf(identity);
205
+ if (!memberId) throw new Error(`Ctrl orchestration.${field} identity ${identity} is not admitted.`);
206
+ const memberOrchestration = facts.get(memberId)!.orchestration as Record<OrchestrationField, Record<string, FlowClosedValue>>;
207
+ memberOrchestration[field][identity] = fact;
142
208
  }
143
209
  }
144
- const normalized = normalizeFlowRunCheckpoint(root);
145
- return normalized;
210
+ const leaves = memberIds.map((memberId) => {
211
+ const definitionStepId = memberId.startsWith('definition:') ? memberId.slice(11) : undefined;
212
+ return createRunStepLeaf(ctrlKey(memberId), definitionStepId, {
213
+ schemaVersion: 'depa.ctrl-run-step-state/v1',
214
+ memberId,
215
+ definitionStepId: definitionStepId ?? null,
216
+ ...facts.get(memberId)!,
217
+ }, definitionStepId ? stepExtensionFacts(checkpoint, definitionStepId) : {});
218
+ });
219
+ return {
220
+ control: {
221
+ schemaVersion: 'depa.flow-run-checkpoint-control/v1',
222
+ checkpoint: envelope(checkpoint),
223
+ profile: {
224
+ kind: checkpoint.profile.kind,
225
+ definitionStepIds,
226
+ snapshot: {
227
+ ...snapshotControl,
228
+ orchestration: Object.fromEntries(ORCHESTRATION_FIELDS.map((field) => [field, {}])),
229
+ },
230
+ ...(checkpoint.profile.kind === 'AICtrlWorkflow' ? { ai: checkpoint.profile.ai } : {}),
231
+ },
232
+ } as unknown as FlowClosedObject,
233
+ controlRecords: [] as readonly ImmutableStepRecord[],
234
+ records: leaves.map(({ leaf }) => leaf),
235
+ materialRecords: leaves.flatMap(({ materialRecords }) => materialRecords),
236
+ };
237
+ }
238
+
239
+ function partitionData(checkpoint: AIWorkflowFlowRunCheckpoint, definitionStepIds?: readonly string[]) {
240
+ if (checkpoint.profile.kind !== 'AIDataWorkflow') throw new Error('Data profile expected.');
241
+ const { binding, nodes, ...runGraphControl } = checkpoint.profile.runGraph as FlowClosedObject;
242
+ const nodeMap = object(nodes, 'data nodes');
243
+ const expected = dataMembership(runGraphControl, binding, definitionStepIds);
244
+ if (canonicalJson(expected) !== canonicalJson(Object.keys(nodeMap).sort(compare))) {
245
+ throw new Error('Data membership differs from frozen binding plus accepted patch facts.');
246
+ }
247
+ const bindingRecord = createImmutableStepRecord('data-binding', binding);
248
+ const leaves = expected.map((nodeId) => createRunStepLeaf(dataKey(nodeId), nodeId, {
249
+ schemaVersion: 'depa.data-run-step-state/v1', nodeId, node: nodeMap[nodeId],
250
+ }, stepExtensionFacts(checkpoint, nodeId)));
251
+ return {
252
+ control: {
253
+ schemaVersion: 'depa.flow-run-checkpoint-control/v1',
254
+ checkpoint: envelope(checkpoint),
255
+ profile: {
256
+ kind: 'AIDataWorkflow',
257
+ runGraph: { ...runGraphControl, bindingRecordDigest: bindingRecord.digest },
258
+ ai: checkpoint.profile.ai,
259
+ },
260
+ } as unknown as FlowClosedObject,
261
+ controlRecords: [bindingRecord],
262
+ records: leaves.map(({ leaf }) => leaf),
263
+ materialRecords: leaves.flatMap(({ materialRecords }) => materialRecords),
264
+ };
146
265
  }
147
266
 
148
- function exactKeys(value: unknown, expected: readonly string[], label: string): Record<string, unknown> {
149
- if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
150
- const keys = Object.keys(value).sort(compareCodeUnits);
151
- const wanted = [...expected].sort(compareCodeUnits);
152
- if (canonicalJson(keys) !== canonicalJson(wanted)) throw new Error(`${label} fields are invalid.`);
153
- return value as Record<string, unknown>;
267
+ function reconstruct(
268
+ head: FlowRunCheckpointHead,
269
+ records: readonly ImmutableStepRecord[],
270
+ controlRecords: ReadonlyMap<DefinitionStepDigest, ImmutableStepRecord>,
271
+ readMaterial: (digest: DefinitionStepDigest) => ImmutableStepRecord,
272
+ ): AIWorkflowFlowRunCheckpoint {
273
+ const control = object(head.control, 'checkpoint control');
274
+ if (control.schemaVersion !== 'depa.flow-run-checkpoint-control/v1') throw new Error('Checkpoint control schema is invalid.');
275
+ const root = object(control.checkpoint, 'checkpoint envelope');
276
+ const profileControl = object(control.profile, 'profile control');
277
+ let profile: FlowClosedObject;
278
+ let controllerSidecars: FlowClosedObject;
279
+ let nodeSidecars: FlowClosedObject;
280
+ const stepExtensionsByStepId: Record<string, Record<string, FlowClosedValue>> = {};
281
+ const readLeaf = (record: ImmutableStepRecord, expectedStepId: string | undefined): ImmutableStepRecord => {
282
+ const leaf = exact(record.value, ['schemaVersion', 'stepId', 'coreRecordDigest', 'extensionRecordDigests'], 'run step leaf');
283
+ if (leaf.schemaVersion !== 'depa.run-step-state-leaf/v1' || leaf.stepId !== (expectedStepId ?? null)
284
+ || typeof leaf.coreRecordDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(leaf.coreRecordDigest)) {
285
+ throw new Error('Run step leaf identity is invalid.');
286
+ }
287
+ const core = readMaterial(leaf.coreRecordDigest as DefinitionStepDigest);
288
+ if (core.key !== coreKey(record.key)) throw new Error('Run step core record identity is invalid.');
289
+ const extensionDigests = object(leaf.extensionRecordDigests, 'run step extension digests');
290
+ if (Object.keys(extensionDigests).length > 0 && !expectedStepId) {
291
+ throw new Error('Runtime-only RunStepState cannot own definition extensions.');
292
+ }
293
+ for (const [kind, value] of entries(extensionDigests, 'run step extension digests')) {
294
+ if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error('Run step extension digest is invalid.');
295
+ const extension = readMaterial(value as DefinitionStepDigest);
296
+ if (extension.key !== extensionKey(expectedStepId!, kind)) throw new Error('Run step extension record identity is invalid.');
297
+ (stepExtensionsByStepId[expectedStepId!] ??= {})[kind] = extension.value;
298
+ }
299
+ return core;
300
+ };
301
+ if (profileControl.kind === 'AIDataWorkflow') {
302
+ if (controlRecords.size !== 1) throw new Error('Data checkpoint requires exactly one binding control record.');
303
+ const runGraphControl = object(profileControl.runGraph, 'data runGraph control');
304
+ if (typeof runGraphControl.bindingRecordDigest !== 'string') throw new Error('Data binding record digest is invalid.');
305
+ const bindingRecordDigest = runGraphControl.bindingRecordDigest as DefinitionStepDigest;
306
+ const bindingRecord = controlRecords.get(bindingRecordDigest);
307
+ if (!bindingRecord || bindingRecord.key !== 'data-binding') throw new Error('Data binding control record is missing.');
308
+ const { bindingRecordDigest: _bindingRecordDigest, ...runGraph } = runGraphControl;
309
+ const binding = object(bindingRecord.value, 'data binding control record');
310
+ const bindingDefinition = object(binding.definition, 'data binding definition');
311
+ if (!Array.isArray(bindingDefinition.declarationOrder)) throw new Error('Data binding declarationOrder is required.');
312
+ const expected = dataMembership(runGraph, binding, bindingDefinition.declarationOrder as readonly string[]);
313
+ const nodes: Record<string, FlowClosedValue> = {};
314
+ for (const record of records) {
315
+ if (!record.key.startsWith('data/')) throw new Error('Data step leaf key is invalid.');
316
+ const leafStepId = JSON.parse(record.key.slice(5)) as unknown;
317
+ if (typeof leafStepId !== 'string') throw new Error('Data step leaf identity is invalid.');
318
+ const value = object(readLeaf(record, leafStepId).value, 'data step record');
319
+ if (value.schemaVersion !== 'depa.data-run-step-state/v1' || typeof value.nodeId !== 'string' || record.key !== dataKey(value.nodeId) || nodes[value.nodeId] !== undefined) {
320
+ throw new Error('Data step record identity is invalid.');
321
+ }
322
+ nodes[value.nodeId] = object(value.node, `data step ${value.nodeId}`);
323
+ }
324
+ if (canonicalJson(expected) !== canonicalJson(Object.keys(nodes).sort(compare))) throw new Error('Data sidecar membership is invalid.');
325
+ const restoredRunGraph: FlowClosedObject = { ...runGraph, binding, nodes };
326
+ profile = { kind: 'AIDataWorkflow', runGraph: restoredRunGraph, ai: profileControl.ai as FlowClosedValue };
327
+ const declarationOrder = restoredRunGraph.declarationOrder as readonly string[];
328
+ const statuses = declarationOrder.map((nodeId) => object(nodes[nodeId], `data step ${nodeId}`).status).filter((status) => status !== 'Removed');
329
+ const status = statuses.some((item) => item === 'Failed') ? 'Failed'
330
+ : statuses.some((item) => item === 'Running') ? 'Running'
331
+ : statuses.length > 0 && statuses.every((item) => item === 'Succeeded' || item === 'Reused') ? 'Succeeded' : 'Pending';
332
+ controllerSidecars = { status };
333
+ nodeSidecars = Object.fromEntries(declarationOrder.map((nodeId) => {
334
+ const node = object(nodes[nodeId], `data step ${nodeId}`);
335
+ return [nodeId, { status: node.status as FlowClosedValue, generation: node.generation as FlowClosedValue }];
336
+ }));
337
+ } else if (profileControl.kind === 'WorkCtrlFlow' || profileControl.kind === 'AICtrlWorkflow') {
338
+ if (controlRecords.size !== 0) throw new Error('Ctrl checkpoint cannot admit data control records.');
339
+ if (!Array.isArray(profileControl.definitionStepIds) || profileControl.definitionStepIds.some((id) => typeof id !== 'string' || !id)) throw new Error('Ctrl definition membership is invalid.');
340
+ const definitionStepIds = profileControl.definitionStepIds as readonly string[];
341
+ if (new Set(definitionStepIds).size !== definitionStepIds.length) throw new Error('Ctrl definition membership is duplicated.');
342
+ const snapshotControl = object(profileControl.snapshot, 'ctrl snapshot control');
343
+ const orchestrationOutput = Object.fromEntries(
344
+ ORCHESTRATION_FIELDS.map((field) => [field, {}]),
345
+ ) as Record<OrchestrationField, Record<string, FlowClosedValue>>;
346
+ const branchDecisions: Record<string, FlowClosedValue> = {};
347
+ const nodeResults: Record<string, FlowClosedValue> = {};
348
+ const waitResults: Record<string, FlowClosedValue> = {};
349
+ const output = {
350
+ branchDecisions,
351
+ nodeResults,
352
+ waitResults,
353
+ orchestration: orchestrationOutput,
354
+ };
355
+ const actual: string[] = [];
356
+ for (const record of records) {
357
+ if (!record.key.startsWith('ctrl/')) throw new Error('Ctrl step leaf key is invalid.');
358
+ const memberId = JSON.parse(record.key.slice(5)) as unknown;
359
+ if (typeof memberId !== 'string') throw new Error('Ctrl step leaf identity is invalid.');
360
+ const expectedStepId = memberId.startsWith('definition:') ? memberId.slice(11) : undefined;
361
+ const value = object(readLeaf(record, expectedStepId).value, 'ctrl step record');
362
+ if (value.schemaVersion !== 'depa.ctrl-run-step-state/v1' || typeof value.memberId !== 'string' || record.key !== ctrlKey(value.memberId)) throw new Error('Ctrl step record identity is invalid.');
363
+ const projectedDefinition = value.memberId.startsWith('definition:') ? value.memberId.slice(11) : null;
364
+ if (value.definitionStepId !== projectedDefinition) throw new Error('Ctrl definition member projection is invalid.');
365
+ actual.push(value.memberId);
366
+ for (const field of ['branchDecisions', 'nodeResults', 'waitResults'] as const) {
367
+ for (const [key, fact] of entries(value[field], `ctrl ${field}`)) {
368
+ const owner = definitionOwner(key, definitionStepIds);
369
+ if (projectedDefinition !== null ? owner !== projectedDefinition : value.memberId !== `runtime:${key}`) throw new Error(`Ctrl ${field} member is invalid.`);
370
+ if (Object.prototype.hasOwnProperty.call(output[field], key)) throw new Error(`Ctrl ${field} member is duplicated.`);
371
+ output[field][key] = fact;
372
+ }
373
+ }
374
+ const recordOrchestration = object(value.orchestration, 'ctrl record orchestration');
375
+ for (const field of ORCHESTRATION_FIELDS) {
376
+ for (const [key, fact] of entries(recordOrchestration[field], `ctrl orchestration.${field}`)) {
377
+ const owner = definitionOwner(key, definitionStepIds);
378
+ if (projectedDefinition !== null ? owner !== projectedDefinition : value.memberId !== `runtime:${key}`) throw new Error(`Ctrl orchestration.${field} member is invalid.`);
379
+ if (Object.prototype.hasOwnProperty.call(orchestrationOutput[field], key)) throw new Error(`Ctrl orchestration.${field} member is duplicated.`);
380
+ orchestrationOutput[field][key] = fact;
381
+ }
382
+ }
383
+ }
384
+ const runtimeMembers = runtimeCtrlMembers(orchestrationOutput, definitionStepIds);
385
+ const expected = [...definitionStepIds.map((id) => `definition:${id}`), ...runtimeMembers.map((id) => `runtime:${id}`)].sort(compare);
386
+ if (canonicalJson(expected) !== canonicalJson(actual.sort(compare))) throw new Error('Ctrl sidecar membership is invalid.');
387
+ const snapshot: FlowClosedObject = { ...snapshotControl, ...output };
388
+ profile = profileControl.kind === 'AICtrlWorkflow'
389
+ ? { kind: 'AICtrlWorkflow', snapshot, ai: profileControl.ai as FlowClosedValue }
390
+ : { kind: 'WorkCtrlFlow', snapshot };
391
+ controllerSidecars = {
392
+ status: snapshot.status as FlowClosedValue,
393
+ lastTickNo: snapshot.lastTickNo as FlowClosedValue,
394
+ branchDecisions: output.branchDecisions,
395
+ orchestration: output.orchestration,
396
+ openWaitHandles: snapshot.openWaitHandles as FlowClosedValue,
397
+ closedWaitHandles: snapshot.closedWaitHandles as FlowClosedValue,
398
+ appliedResumeSignals: snapshot.appliedResumeSignals as FlowClosedValue,
399
+ };
400
+ nodeSidecars = { nodeResults: output.nodeResults, waitResults: output.waitResults };
401
+ } else throw new Error('Checkpoint profile control kind is invalid.');
402
+ const stepExtensions = Object.keys(stepExtensionsByStepId).length === 0 ? undefined : {
403
+ schemaVersion: 'depa.flow-run-step-extensions/v1',
404
+ byStepId: stepExtensionsByStepId,
405
+ };
406
+ return normalizeFlowRunCheckpoint({
407
+ ...root,
408
+ instanceId: head.instanceId,
409
+ runId: head.runId,
410
+ definition: head.definition,
411
+ version: head.version,
412
+ controllerSidecars,
413
+ nodeSidecars,
414
+ ...(stepExtensions === undefined ? {} : { stepExtensions }),
415
+ profile,
416
+ });
154
417
  }
155
418
 
419
+ function exact(value: unknown, fields: readonly string[], label: string): Record<string, unknown> {
420
+ const record = object(value, label) as Record<string, unknown>;
421
+ if (canonicalJson(Object.keys(record).sort(compare)) !== canonicalJson([...fields].sort(compare))) throw new Error(`${label} fields are invalid.`);
422
+ return record;
423
+ }
156
424
  function normalizeHead(value: unknown): FlowRunCheckpointHead {
157
- const record = exactKeys(value, [
158
- 'schemaVersion', 'instanceId', 'runId', 'definition', 'version', 'logicalDigest', 'runStepStateRootDigest',
159
- ], 'checkpoint head');
160
- if (record.schemaVersion !== 'depa.flow-run-checkpoint-head/v1'
161
- || typeof record.instanceId !== 'string' || !record.instanceId
162
- || typeof record.runId !== 'string' || !record.runId
163
- || !Number.isSafeInteger(record.version) || (record.version as number) < 0
425
+ const record = exact(value, ['schemaVersion', 'instanceId', 'runId', 'definition', 'version', 'logicalDigest', 'control', 'runStepStateRootDigest'], 'checkpoint head');
426
+ if (record.schemaVersion !== 'depa.flow-run-checkpoint-head/v1' || typeof record.instanceId !== 'string' || !record.instanceId
427
+ || typeof record.runId !== 'string' || !record.runId || !Number.isSafeInteger(record.version) || (record.version as number) < 0
164
428
  || typeof record.logicalDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(record.logicalDigest)
165
- || typeof record.runStepStateRootDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(record.runStepStateRootDigest)) {
166
- throw new Error('Checkpoint head scalar fields are invalid.');
167
- }
168
- const definition = exactKeys(record.definition, ['revision', 'digest', 'provenance'], 'checkpoint definition');
169
- if (typeof definition.revision !== 'string' || !definition.revision
170
- || typeof definition.digest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(definition.digest)) {
171
- throw new Error('Checkpoint definition identity is invalid.');
172
- }
429
+ || typeof record.runStepStateRootDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(record.runStepStateRootDigest)) throw new Error('Checkpoint head scalar fields are invalid.');
430
+ const definition = exact(record.definition, ['revision', 'digest', 'provenance'], 'checkpoint definition');
431
+ if (typeof definition.revision !== 'string' || !definition.revision || typeof definition.digest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(definition.digest)) throw new Error('Checkpoint definition identity is invalid.');
432
+ object(record.control, 'checkpoint control');
173
433
  return Object.freeze(record as unknown as FlowRunCheckpointHead);
174
434
  }
175
-
176
435
  function readJson(file: string): unknown {
177
436
  const stat = fs.lstatSync(file);
178
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Sidecar path ${file} must be a regular file.`);
179
- let descriptor: number | undefined;
180
- try {
181
- descriptor = fs.openSync(file, fs.constants.O_RDONLY | NO_FOLLOW);
182
- return JSON.parse(fs.readFileSync(descriptor, 'utf8')) as unknown;
183
- } finally {
184
- if (descriptor !== undefined) fs.closeSync(descriptor);
185
- }
437
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Sidecar path ${file} must be regular.`);
438
+ let fd: number | undefined;
439
+ try { fd = fs.openSync(file, fs.constants.O_RDONLY | NO_FOLLOW); return JSON.parse(fs.readFileSync(fd, 'utf8')) as unknown; }
440
+ finally { if (fd !== undefined) fs.closeSync(fd); }
186
441
  }
187
-
188
- function readNode(treeDir: string, nodeDigest: DefinitionStepDigest): StepMerkleNode {
189
- const file = path.join(treeDir, digestFileName(nodeDigest));
190
- const decoded = readJson(file);
191
- const kind = decoded && typeof decoded === 'object' && !Array.isArray(decoded)
192
- ? (decoded as { readonly kind?: unknown }).kind
193
- : undefined;
194
- const value = exactKeys(
195
- decoded,
196
- kind === 'leaf'
197
- ? ['kind', 'key', 'recordDigest', 'digest']
198
- : ['kind', 'firstKey', 'lastKey', 'leftDigest', 'rightDigest', 'digest'],
199
- 'sidecar tree node',
200
- ) as unknown as StepMerkleNode;
201
- const expected = value.kind === 'leaf'
202
- ? digest({ kind: 'leaf', key: value.key, recordDigest: value.recordDigest })
203
- : value.kind === 'branch'
204
- ? digest({
205
- kind: 'branch', firstKey: value.firstKey, lastKey: value.lastKey,
206
- leftDigest: value.leftDigest, rightDigest: value.rightDigest,
207
- })
208
- : undefined;
209
- if (expected !== nodeDigest || value.digest !== nodeDigest) throw new Error(`Sidecar tree node ${nodeDigest} digest mismatch.`);
442
+ function readNode(dir: string, nodeDigest: DefinitionStepDigest): StepMerkleNode {
443
+ const decoded = readJson(path.join(dir, digestFileName(nodeDigest)));
444
+ const kind = object(decoded, 'tree node').kind;
445
+ const value = exact(decoded, kind === 'leaf' ? ['kind', 'key', 'recordDigest', 'digest'] : ['kind', 'firstKey', 'lastKey', 'leftDigest', 'rightDigest', 'digest'], 'tree node') as unknown as StepMerkleNode;
446
+ const expected = value.kind === 'leaf' ? digest({ kind: 'leaf', key: value.key, recordDigest: value.recordDigest })
447
+ : value.kind === 'branch' ? digest({ kind: 'branch', firstKey: value.firstKey, lastKey: value.lastKey, leftDigest: value.leftDigest, rightDigest: value.rightDigest }) : undefined;
448
+ if (expected !== nodeDigest || value.digest !== nodeDigest) throw new Error('Tree node digest mismatch.');
210
449
  return value;
211
450
  }
212
-
213
- function readRecord(recordDir: string, recordDigest: DefinitionStepDigest): ImmutableStepRecord {
214
- const value = exactKeys(
215
- readJson(path.join(recordDir, digestFileName(recordDigest))),
216
- ['schemaVersion', 'key', 'value', 'digest'],
217
- 'sidecar record',
218
- ) as unknown as ImmutableStepRecord;
451
+ function readRecord(dir: string, recordDigest: DefinitionStepDigest): ImmutableStepRecord {
452
+ const value = exact(readJson(path.join(dir, digestFileName(recordDigest))), ['schemaVersion', 'key', 'value', 'digest'], 'step record') as unknown as ImmutableStepRecord;
219
453
  const expected = createImmutableStepRecord(value.key, value.value);
220
- if (expected.digest !== recordDigest || value.digest !== recordDigest || canonicalJson(expected) !== canonicalJson(value)) {
221
- throw new Error(`Sidecar record ${recordDigest} digest mismatch.`);
222
- }
454
+ if (expected.digest !== recordDigest || canonicalJson(expected) !== canonicalJson(value)) throw new Error('Step record digest mismatch.');
223
455
  return expected;
224
456
  }
225
-
226
- function collectRecords(rootDir: string, rootDigest: DefinitionStepDigest): ImmutableStepRecord[] {
227
- const treeDir = path.join(rootDir, 'trees');
228
- const recordDir = path.join(rootDir, 'records');
229
- const records = new Map<string, ImmutableStepRecord>();
230
- const visited = new Set<string>();
231
- const visit = (nodeDigest: DefinitionStepDigest): void => {
232
- if (visited.has(nodeDigest)) return;
233
- visited.add(nodeDigest);
234
- const node = readNode(treeDir, nodeDigest);
235
- if (node.kind === 'leaf') {
236
- const record = readRecord(recordDir, node.recordDigest);
237
- if (record.key !== node.key) throw new Error(`Sidecar leaf ${nodeDigest} key mismatch.`);
238
- if (records.has(record.key)) throw new Error(`Sidecar record key ${record.key} is duplicated.`);
239
- records.set(record.key, record);
240
- return;
241
- }
242
- visit(node.leftDigest);
243
- visit(node.rightDigest);
457
+ function collectRecords(root: string, rootDigest: DefinitionStepDigest): ImmutableStepRecord[] {
458
+ const records = new Map<string, ImmutableStepRecord>(); const visited = new Set<string>();
459
+ const visit = (value: DefinitionStepDigest): void => {
460
+ if (visited.has(value)) return; visited.add(value);
461
+ const node = readNode(path.join(root, 'trees'), value);
462
+ if (node.kind === 'leaf') { const record = readRecord(path.join(root, 'records'), node.recordDigest); if (record.key !== node.key || records.has(record.key)) throw new Error('Step leaf identity is invalid.'); records.set(record.key, record); }
463
+ else { visit(node.leftDigest); visit(node.rightDigest); }
244
464
  };
245
- visit(rootDigest);
246
- return [...records.values()].sort((left, right) => compareCodeUnits(left.key, right.key));
465
+ visit(rootDigest); return [...records.values()].sort((a, b) => compare(a.key, b.key));
247
466
  }
248
467
 
249
- function writeImmutable(file: string, value: unknown): void {
250
- fs.mkdirSync(path.dirname(file), { recursive: true });
251
- if (fs.existsSync(file)) {
252
- if (canonicalJson(readJson(file)) !== canonicalJson(value)) throw new Error(`Immutable sidecar collision at ${file}`);
253
- return;
254
- }
255
- const staging = `${file}.candidate-${randomUUID()}`;
256
- let descriptor: number | undefined;
468
+ function runEvent(runtime: CheckpointDurabilityRuntime | undefined, event: CheckpointDurabilityEvent, operation: () => void): void {
469
+ runtime?.beforeEvent?.(event); operation(); runtime?.onEvent?.(event);
470
+ }
471
+ function syncDirectory(directory: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
472
+ let fd: number | undefined;
473
+ try { fd = fs.openSync(directory, fs.constants.O_RDONLY); runEvent(runtime, { operation: 'directory-fsync', role, path: directory }, () => fs.fsyncSync(fd!)); }
474
+ finally { if (fd !== undefined) fs.closeSync(fd); }
475
+ }
476
+ export function createDirectoryDurably(directory: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
477
+ runEvent(runtime, { operation: 'directory-create', role, path: directory }, () => fs.mkdirSync(directory));
478
+ syncDirectory(path.dirname(directory), role, runtime);
479
+ }
480
+ export function writeDurableCandidate(file: string, bytes: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
481
+ let fd: number | undefined;
482
+ try { fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NO_FOLLOW, 0o600); fs.writeFileSync(fd, bytes, 'utf8'); runEvent(runtime, { operation: 'file-fsync', role, path: file }, () => fs.fsyncSync(fd!)); }
483
+ finally { if (fd !== undefined) fs.closeSync(fd); }
484
+ }
485
+ export function renameDurably(source: string, target: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
486
+ runEvent(runtime, { operation: 'rename', role, path: source, targetPath: target }, () => fs.renameSync(source, target));
487
+ syncDirectory(path.dirname(target), role, runtime);
488
+ }
489
+ function writeImmutable(file: string, value: unknown, role: 'record' | 'tree' | 'receipt', runtime?: CheckpointDurabilityRuntime): void {
490
+ const parent = fs.lstatSync(path.dirname(file));
491
+ if (!parent.isDirectory() || parent.isSymbolicLink()) throw new Error(`Immutable ${role} parent must be a real directory.`);
492
+ if (fs.existsSync(file)) { if (canonicalJson(readJson(file)) !== canonicalJson(value)) throw new Error(`Immutable collision at ${file}`); return; }
493
+ const candidate = `${file}.candidate-${randomUUID()}`;
257
494
  try {
258
- descriptor = fs.openSync(staging, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NO_FOLLOW, 0o600);
259
- fs.writeFileSync(descriptor, canonicalJson(value), 'utf8');
260
- fs.fsyncSync(descriptor);
261
- fs.closeSync(descriptor);
262
- descriptor = undefined;
263
- if (canonicalJson(readJson(staging)) !== canonicalJson(value)) throw new Error(`Sidecar candidate ${staging} readback mismatch.`);
264
- try {
265
- fs.renameSync(staging, file);
266
- } catch (error) {
267
- if (!fs.existsSync(file) || canonicalJson(readJson(file)) !== canonicalJson(value)) throw error;
268
- fs.unlinkSync(staging);
269
- }
270
- } finally {
271
- if (descriptor !== undefined) fs.closeSync(descriptor);
272
- if (fs.existsSync(staging)) fs.unlinkSync(staging);
273
- }
495
+ writeDurableCandidate(candidate, canonicalJson(value), role, runtime);
496
+ if (canonicalJson(readJson(candidate)) !== canonicalJson(value)) throw new Error('Immutable candidate readback differs.');
497
+ renameDurably(candidate, file, role, runtime);
498
+ } finally { if (fs.existsSync(candidate)) fs.unlinkSync(candidate); }
274
499
  }
275
500
 
276
- export function encodeCheckpointSidecars(
277
- checkpointValue: AIWorkflowFlowRunCheckpoint,
278
- previousTree: StepMerkleTree | undefined,
279
- ): EncodedCheckpoint {
501
+ export function encodeCheckpointSidecars(checkpointValue: AIWorkflowFlowRunCheckpoint, previousTree: StepMerkleTree | undefined, config: CheckpointSidecarEncodeConfig = {}): EncodedCheckpoint {
280
502
  const checkpoint = normalizeFlowRunCheckpoint(checkpointValue);
281
- const records = flatten(checkpoint as unknown as FlowClosedValue);
282
- const tree = buildStepMerkleTree(records);
283
- const treeDiff = diffStepMerkleTrees(previousTree, tree);
284
- const previousRecords = new Set<string>();
285
- if (previousTree) {
286
- Object.values(previousTree.nodes).forEach((node) => {
287
- if (node.kind === 'leaf') previousRecords.add(node.recordDigest);
288
- });
289
- }
290
- const addedRecordDigests = records.map((record) => record.digest).filter((value) => !previousRecords.has(value)).sort(compareCodeUnits);
503
+ const partitioned = checkpoint.profile.kind === 'AIDataWorkflow' ? partitionData(checkpoint, config.definitionStepIds) : partitionCtrl(checkpoint, config.definitionStepIds);
504
+ const tree = buildStepMerkleTree(partitioned.records); const treeDiff = diffStepMerkleTrees(previousTree, tree);
505
+ const previousRecords = new Set<string>(); Object.values(previousTree?.nodes ?? {}).forEach((node) => { if (node.kind === 'leaf') previousRecords.add(node.recordDigest); });
506
+ const addedRecordDigests = partitioned.records.map((record) => record.digest).filter((value) => !previousRecords.has(value)).sort(compare);
291
507
  const logicalDigest = digest(checkpoint);
292
- const head = Object.freeze({
293
- schemaVersion: 'depa.flow-run-checkpoint-head/v1' as const,
294
- instanceId: checkpoint.instanceId,
295
- runId: checkpoint.runId,
296
- definition: checkpoint.definition,
297
- version: checkpoint.version,
298
- logicalDigest,
299
- runStepStateRootDigest: tree.rootDigest,
300
- });
301
- return {
302
- head,
303
- records,
304
- tree,
305
- receipt: Object.freeze({
306
- schemaVersion: 'depa.flow-run-checkpoint-sidecar-receipt/v1' as const,
307
- version: checkpoint.version,
308
- previousRootDigest: previousTree?.rootDigest ?? null,
309
- rootDigest: tree.rootDigest,
310
- logicalDigest,
311
- addedRecordDigests: Object.freeze(addedRecordDigests),
312
- addedTreeNodeDigests: treeDiff.addedNodeDigests,
313
- }),
314
- };
508
+ const head: FlowRunCheckpointHead = Object.freeze({ schemaVersion: 'depa.flow-run-checkpoint-head/v1', instanceId: checkpoint.instanceId, runId: checkpoint.runId, definition: checkpoint.definition, version: checkpoint.version, logicalDigest, control: partitioned.control, runStepStateRootDigest: tree.rootDigest });
509
+ const previousMaterials = new Set(config.previousMaterialRecordDigests ?? []);
510
+ const addedMaterialRecordDigests = partitioned.materialRecords.map((record) => record.digest)
511
+ .filter((value) => !previousMaterials.has(value)).sort(compare);
512
+ return { head, controlRecords: partitioned.controlRecords, records: partitioned.records, materialRecords: partitioned.materialRecords, tree, receipt: Object.freeze({ schemaVersion: 'depa.flow-run-checkpoint-sidecar-receipt/v1', version: checkpoint.version, previousRootDigest: previousTree?.rootDigest ?? null, rootDigest: tree.rootDigest, logicalDigest, controlRecordDigests: Object.freeze(partitioned.controlRecords.map((record) => record.digest).sort(compare)), addedMaterialRecordDigests: Object.freeze(addedMaterialRecordDigests), addedRecordDigests: Object.freeze(addedRecordDigests), addedTreeNodeDigests: treeDiff.addedNodeDigests }) };
315
513
  }
316
-
317
- export function persistCheckpointSidecars(runDir: string, encoded: EncodedCheckpoint): void {
318
- const root = path.join(runDir, 'step-space');
319
- const addedRecords = new Set(encoded.receipt.addedRecordDigests);
320
- for (const record of encoded.records.filter((value) => addedRecords.has(value.digest))) {
321
- writeImmutable(path.join(root, 'records', digestFileName(record.digest)), record);
322
- }
323
- const addedNodes = new Set(encoded.receipt.addedTreeNodeDigests);
324
- for (const node of Object.values(encoded.tree.nodes).filter((value) => addedNodes.has(value.digest))) {
325
- writeImmutable(path.join(root, 'trees', digestFileName(node.digest)), node);
326
- }
327
- writeImmutable(path.join(root, 'receipts', `${encoded.head.version}.json`), encoded.receipt);
514
+ export function persistCheckpointSidecars(runDir: string, encoded: EncodedCheckpoint, runtime?: CheckpointDurabilityRuntime): void {
515
+ const root = path.join(runDir, 'step-space'); const addedRecords = new Set(encoded.receipt.addedRecordDigests); const addedMaterials = new Set(encoded.receipt.addedMaterialRecordDigests); const addedNodes = new Set(encoded.receipt.addedTreeNodeDigests);
516
+ for (const record of encoded.controlRecords) writeImmutable(path.join(root, 'controls', digestFileName(record.digest)), record, 'record', runtime);
517
+ for (const record of encoded.materialRecords.filter((item) => addedMaterials.has(item.digest))) writeImmutable(path.join(root, 'records', digestFileName(record.digest)), record, 'record', runtime);
518
+ for (const record of encoded.records.filter((item) => addedRecords.has(item.digest))) writeImmutable(path.join(root, 'records', digestFileName(record.digest)), record, 'record', runtime);
519
+ for (const node of Object.values(encoded.tree.nodes).filter((item) => addedNodes.has(item.digest))) writeImmutable(path.join(root, 'trees', digestFileName(node.digest)), node, 'tree', runtime);
520
+ writeImmutable(path.join(root, 'receipts', `${encoded.head.version}.json`), encoded.receipt, 'receipt', runtime);
328
521
  }
329
-
330
- function assertReceipt(root: string, head: FlowRunCheckpointHead): void {
331
- const value = exactKeys(
332
- readJson(path.join(root, 'receipts', `${head.version}.json`)),
333
- [
334
- 'schemaVersion', 'version', 'previousRootDigest', 'rootDigest', 'logicalDigest',
335
- 'addedRecordDigests', 'addedTreeNodeDigests',
336
- ],
337
- 'sidecar receipt',
338
- );
339
- const digestArray = (field: 'addedRecordDigests' | 'addedTreeNodeDigests'): readonly string[] => {
340
- const entries = value[field];
341
- if (!Array.isArray(entries)
342
- || Reflect.ownKeys(entries).some((key) => typeof key !== 'string'
343
- || (key !== 'length' && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= entries.length)))
344
- || entries.some((entry) => typeof entry !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(entry))) {
345
- throw new Error(`Sidecar receipt ${field} is invalid.`);
346
- }
347
- const sorted = [...entries].sort(compareCodeUnits);
348
- if (new Set(sorted).size !== sorted.length || canonicalJson(entries) !== canonicalJson(sorted)) {
349
- throw new Error(`Sidecar receipt ${field} must be sorted and unique.`);
350
- }
351
- return entries;
352
- };
353
- digestArray('addedRecordDigests');
354
- digestArray('addedTreeNodeDigests');
355
- if (value.schemaVersion !== 'depa.flow-run-checkpoint-sidecar-receipt/v1'
356
- || value.version !== head.version
357
- || value.rootDigest !== head.runStepStateRootDigest
358
- || value.logicalDigest !== head.logicalDigest
359
- || (value.previousRootDigest !== null
360
- && (typeof value.previousRootDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value.previousRootDigest)))) {
361
- throw new Error('Sidecar receipt does not bind the accepted head.');
362
- }
522
+ function assertReceipt(root: string, head: FlowRunCheckpointHead): Record<string, unknown> {
523
+ const value = exact(readJson(path.join(root, 'receipts', `${head.version}.json`)), ['schemaVersion', 'version', 'previousRootDigest', 'rootDigest', 'logicalDigest', 'controlRecordDigests', 'addedMaterialRecordDigests', 'addedRecordDigests', 'addedTreeNodeDigests'], 'receipt');
524
+ for (const field of ['controlRecordDigests', 'addedMaterialRecordDigests', 'addedRecordDigests', 'addedTreeNodeDigests'] as const) { const list = value[field]; if (!Array.isArray(list) || list.some((item) => typeof item !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(item)) || canonicalJson(list) !== canonicalJson([...list].sort(compare)) || new Set(list).size !== list.length) throw new Error(`Receipt ${field} is invalid.`); }
525
+ if (value.schemaVersion !== 'depa.flow-run-checkpoint-sidecar-receipt/v1' || value.version !== head.version || value.rootDigest !== head.runStepStateRootDigest || value.logicalDigest !== head.logicalDigest || (value.previousRootDigest !== null && (typeof value.previousRootDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value.previousRootDigest)))) throw new Error('Receipt does not bind head.');
526
+ return value;
363
527
  }
364
-
365
- export function readCheckpointHeadAndLogical(file: string): {
366
- readonly head: FlowRunCheckpointHead;
367
- readonly checkpoint: AIWorkflowFlowRunCheckpoint;
368
- readonly tree: StepMerkleTree;
369
- } {
370
- const head = normalizeHead(readJson(file));
371
- const root = path.join(path.dirname(file), 'step-space');
372
- assertReceipt(root, head);
373
- const records = collectRecords(root, head.runStepStateRootDigest);
374
- const checkpoint = reconstruct(records);
375
- if (digest(checkpoint) !== head.logicalDigest
376
- || checkpoint.instanceId !== head.instanceId
377
- || checkpoint.runId !== head.runId
378
- || checkpoint.version !== head.version
379
- || canonicalJson(checkpoint.definition) !== canonicalJson(head.definition)) {
380
- throw new Error('Checkpoint head and reconstructed logical value differ.');
528
+ export function readCheckpointHeadAndLogical(file: string) {
529
+ const head = normalizeHead(readJson(file)); const root = path.join(path.dirname(file), 'step-space'); const receipt = assertReceipt(root, head);
530
+ const controlRecords = new Map<DefinitionStepDigest, ImmutableStepRecord>();
531
+ for (const value of receipt.controlRecordDigests as readonly DefinitionStepDigest[]) {
532
+ controlRecords.set(value, readRecord(path.join(root, 'controls'), value));
381
533
  }
382
- const nodes: Record<string, StepMerkleNode> = {};
383
- const leafByKey: Record<string, DefinitionStepDigest> = {};
384
- const keyOrder: string[] = [];
385
- const visit = (nodeDigest: DefinitionStepDigest): void => {
386
- if (nodes[nodeDigest]) return;
387
- const node = readNode(path.join(root, 'trees'), nodeDigest);
388
- nodes[nodeDigest] = node;
389
- if (node.kind === 'leaf') {
390
- leafByKey[node.key] = nodeDigest;
391
- keyOrder.push(node.key);
392
- } else {
393
- visit(node.leftDigest);
394
- visit(node.rightDigest);
395
- }
534
+ const materialDigests = new Set<DefinitionStepDigest>();
535
+ const readMaterial = (value: DefinitionStepDigest) => {
536
+ materialDigests.add(value);
537
+ return readRecord(path.join(root, 'records'), value);
396
538
  };
397
- visit(head.runStepStateRootDigest);
398
- keyOrder.sort(compareCodeUnits);
399
- return {
400
- head,
401
- checkpoint,
402
- tree: Object.freeze({
403
- schemaVersion: 'depa.step-merkle-tree/v1',
404
- rootDigest: head.runStepStateRootDigest,
405
- keyOrder: Object.freeze(keyOrder),
406
- leafByKey: Object.freeze(leafByKey),
407
- nodes: Object.freeze(nodes),
408
- }),
409
- };
410
- }
411
-
412
- export function isCheckpointHead(value: unknown): boolean {
413
- return !!value && typeof value === 'object' && !Array.isArray(value)
414
- && (value as { readonly schemaVersion?: unknown }).schemaVersion === 'depa.flow-run-checkpoint-head/v1';
415
- }
416
-
417
- export function checkpointHeadJson(head: FlowRunCheckpointHead): string {
418
- return canonicalJson(head);
539
+ const checkpoint = reconstruct(head, collectRecords(root, head.runStepStateRootDigest), controlRecords, readMaterial);
540
+ if (digest(checkpoint) !== head.logicalDigest || checkpoint.instanceId !== head.instanceId || checkpoint.runId !== head.runId || checkpoint.version !== head.version || canonicalJson(checkpoint.definition) !== canonicalJson(head.definition)) throw new Error('Head and logical checkpoint differ.');
541
+ const nodes: Record<string, StepMerkleNode> = {}; const leafByKey: Record<string, DefinitionStepDigest> = {}; const keyOrder: string[] = [];
542
+ const visit = (value: DefinitionStepDigest): void => { if (nodes[value]) return; const node = readNode(path.join(root, 'trees'), value); nodes[value] = node; if (node.kind === 'leaf') { leafByKey[node.key] = value; keyOrder.push(node.key); } else { visit(node.leftDigest); visit(node.rightDigest); } };
543
+ visit(head.runStepStateRootDigest); keyOrder.sort(compare);
544
+ return { head, checkpoint, materialRecordDigests: Object.freeze([...materialDigests].sort(compare)), tree: Object.freeze({ schemaVersion: 'depa.step-merkle-tree/v1' as const, rootDigest: head.runStepStateRootDigest, keyOrder: Object.freeze(keyOrder), leafByKey: Object.freeze(leafByKey), nodes: Object.freeze(nodes) }) };
419
545
  }
546
+ export const isCheckpointHead = (value: unknown): boolean => !!value && typeof value === 'object' && !Array.isArray(value) && (value as { schemaVersion?: unknown }).schemaVersion === 'depa.flow-run-checkpoint-head/v1';
547
+ export const checkpointHeadJson = (head: FlowRunCheckpointHead): string => canonicalJson(head);