ai-workflow-logic 0.1.3 → 0.1.5
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/package.json +8 -3
- package/src/agent-invocation.ts +282 -0
- package/src/agent-state.ts +293 -0
- package/src/checkpoint-filesystem.ts +1273 -0
- package/src/checkpoint-sidecar.ts +419 -0
- package/src/checkpoint-store.ts +601 -0
- package/src/index.ts +34 -4
- package/src/run-checkpoint.ts +1666 -0
- package/src/run-freeze.ts +57 -1
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
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';
|
|
16
|
+
import { normalizeFlowRunCheckpoint } from './run-checkpoint.js';
|
|
17
|
+
|
|
18
|
+
export interface FlowRunCheckpointHead {
|
|
19
|
+
readonly schemaVersion: 'depa.flow-run-checkpoint-head/v1';
|
|
20
|
+
readonly instanceId: string;
|
|
21
|
+
readonly runId: string;
|
|
22
|
+
readonly definition: AIWorkflowFlowRunCheckpoint['definition'];
|
|
23
|
+
readonly version: number;
|
|
24
|
+
readonly logicalDigest: DefinitionStepDigest;
|
|
25
|
+
readonly runStepStateRootDigest: DefinitionStepDigest;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FlowRunCheckpointSidecarReceipt {
|
|
29
|
+
readonly schemaVersion: 'depa.flow-run-checkpoint-sidecar-receipt/v1';
|
|
30
|
+
readonly version: number;
|
|
31
|
+
readonly previousRootDigest: DefinitionStepDigest | null;
|
|
32
|
+
readonly rootDigest: DefinitionStepDigest;
|
|
33
|
+
readonly logicalDigest: DefinitionStepDigest;
|
|
34
|
+
readonly addedRecordDigests: readonly DefinitionStepDigest[];
|
|
35
|
+
readonly addedTreeNodeDigests: readonly DefinitionStepDigest[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type EncodedCheckpoint = {
|
|
39
|
+
readonly head: FlowRunCheckpointHead;
|
|
40
|
+
readonly records: readonly ImmutableStepRecord[];
|
|
41
|
+
readonly tree: StepMerkleTree;
|
|
42
|
+
readonly receipt: FlowRunCheckpointSidecarReceipt;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type Marker =
|
|
46
|
+
| { readonly kind: 'object' }
|
|
47
|
+
| { readonly kind: 'array'; readonly length: number }
|
|
48
|
+
| { readonly kind: 'value'; readonly value: FlowClosedValue };
|
|
49
|
+
|
|
50
|
+
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
|
+
|
|
56
|
+
function canonicalJson(value: unknown): string {
|
|
57
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
58
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
59
|
+
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')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function digestFileName(value: DefinitionStepDigest): string {
|
|
68
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(value)) throw new Error(`Invalid sidecar digest ${value}`);
|
|
69
|
+
return `${value.slice('sha256:'.length)}.json`;
|
|
70
|
+
}
|
|
71
|
+
|
|
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 }));
|
|
88
|
+
};
|
|
89
|
+
visit(value, []);
|
|
90
|
+
return records;
|
|
91
|
+
}
|
|
92
|
+
|
|
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 };
|
|
102
|
+
}
|
|
103
|
+
if (marker.kind === 'value' && canonicalJson(keys) === canonicalJson(['kind', 'value'])) {
|
|
104
|
+
return { kind: 'value', value: marker.value };
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Invalid sidecar marker for ${record.key}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
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}`);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
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];
|
|
133
|
+
}
|
|
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);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const normalized = normalizeFlowRunCheckpoint(root);
|
|
145
|
+
return normalized;
|
|
146
|
+
}
|
|
147
|
+
|
|
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>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
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
|
|
164
|
+
|| 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
|
+
}
|
|
173
|
+
return Object.freeze(record as unknown as FlowRunCheckpointHead);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readJson(file: string): unknown {
|
|
177
|
+
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
|
+
}
|
|
186
|
+
}
|
|
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.`);
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
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;
|
|
219
|
+
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
|
+
}
|
|
223
|
+
return expected;
|
|
224
|
+
}
|
|
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);
|
|
244
|
+
};
|
|
245
|
+
visit(rootDigest);
|
|
246
|
+
return [...records.values()].sort((left, right) => compareCodeUnits(left.key, right.key));
|
|
247
|
+
}
|
|
248
|
+
|
|
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;
|
|
257
|
+
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
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function encodeCheckpointSidecars(
|
|
277
|
+
checkpointValue: AIWorkflowFlowRunCheckpoint,
|
|
278
|
+
previousTree: StepMerkleTree | undefined,
|
|
279
|
+
): EncodedCheckpoint {
|
|
280
|
+
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);
|
|
291
|
+
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
|
+
};
|
|
315
|
+
}
|
|
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);
|
|
328
|
+
}
|
|
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
|
+
}
|
|
363
|
+
}
|
|
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.');
|
|
381
|
+
}
|
|
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
|
+
}
|
|
396
|
+
};
|
|
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);
|
|
419
|
+
}
|