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.
@@ -0,0 +1,601 @@
1
+ import type {
2
+ AIWorkflowFlowRunCheckpoint,
3
+ FlowClosedObject,
4
+ FlowClosedValue,
5
+ FlowDefinitionArtifactIdentity,
6
+ FlowRunCheckpointCommit,
7
+ FlowRunCheckpointKey,
8
+ FlowRunCheckpointRuntime,
9
+ FlowRunCheckpointStore,
10
+ FlowRunProfileState,
11
+ } from 'ai-workflow-contract';
12
+ import type { WorkCtrlFlowSnapshot, WorkCtrlFlowStore } from 'work-ctrl-flow-contract';
13
+ import { normalizeFlowRunCheckpoint } from './run-checkpoint.js';
14
+
15
+ export interface WorkCtrlFlowCheckpointStoreOptions {
16
+ readonly instanceId: string;
17
+ readonly definition: FlowDefinitionArtifactIdentity;
18
+ readonly config: FlowClosedValue;
19
+ readonly projectOutput?: (snapshot: WorkCtrlFlowSnapshot) => FlowClosedValue;
20
+ }
21
+
22
+ /** Removes optional undefined object fields without relaxing the closed-data validator. */
23
+ export function projectWorkCtrlSnapshotToCheckpointData(
24
+ snapshot: WorkCtrlFlowSnapshot,
25
+ ): WorkCtrlFlowSnapshot {
26
+ const visit = (value: unknown): unknown => {
27
+ if (Array.isArray(value)) return value.map(visit);
28
+ if (value === null || typeof value !== 'object') return value;
29
+ const prototype = Object.getPrototypeOf(value);
30
+ if (prototype !== Object.prototype && prototype !== null) return value;
31
+ return Object.fromEntries(
32
+ Object.entries(value)
33
+ .filter(([, nested]) => nested !== undefined)
34
+ .map(([key, nested]) => [key, visit(nested)]),
35
+ );
36
+ };
37
+ return visit(snapshot) as WorkCtrlFlowSnapshot;
38
+ }
39
+
40
+ function workCtrlControllerSidecars(snapshot: WorkCtrlFlowSnapshot): FlowClosedObject {
41
+ return {
42
+ status: snapshot.status,
43
+ lastTickNo: snapshot.lastTickNo,
44
+ branchDecisions: snapshot.branchDecisions,
45
+ orchestration: snapshot.orchestration,
46
+ openWaitHandles: snapshot.openWaitHandles,
47
+ closedWaitHandles: snapshot.closedWaitHandles,
48
+ appliedResumeSignals: snapshot.appliedResumeSignals,
49
+ } as unknown as FlowClosedObject;
50
+ }
51
+
52
+ function workCtrlNodeSidecars(snapshot: WorkCtrlFlowSnapshot): FlowClosedObject {
53
+ return {
54
+ nodeResults: snapshot.nodeResults,
55
+ waitResults: snapshot.waitResults,
56
+ } as unknown as FlowClosedObject;
57
+ }
58
+
59
+ /** Canonical WorkCtrl adapter; the detached snapshot is only an engine working copy. */
60
+ export function createWorkCtrlFlowCheckpointStore(
61
+ runtime: FlowRunCheckpointRuntime,
62
+ options: WorkCtrlFlowCheckpointStoreOptions,
63
+ ): WorkCtrlFlowStore {
64
+ return {
65
+ async load(treeId) {
66
+ const checkpoint = await loadFlowRunCheckpoint(runtime, {
67
+ instanceId: options.instanceId,
68
+ runId: treeId,
69
+ });
70
+ if (checkpoint === undefined) return undefined;
71
+ if (checkpoint.profile.kind !== 'WorkCtrlFlow') {
72
+ throw new FlowRunCheckpointTransitionError(`Flow run ${treeId} is not a WorkCtrlFlow checkpoint.`);
73
+ }
74
+ return structuredClone(checkpoint.profile.snapshot) as unknown as WorkCtrlFlowSnapshot;
75
+ },
76
+ async save(snapshot) {
77
+ const durableSnapshot = projectWorkCtrlSnapshotToCheckpointData(snapshot);
78
+ const key = { instanceId: options.instanceId, runId: snapshot.treeId };
79
+ const current = await loadFlowRunCheckpoint(runtime, key);
80
+ if (current !== undefined && current.profile.kind !== 'WorkCtrlFlow') {
81
+ throw new FlowRunCheckpointTransitionError(`Flow run ${snapshot.treeId} is not a WorkCtrlFlow checkpoint.`);
82
+ }
83
+ const checkpoint: AIWorkflowFlowRunCheckpoint = {
84
+ schemaVersion: 'depa.flow-run-checkpoint/v1',
85
+ instanceId: options.instanceId,
86
+ runId: snapshot.treeId,
87
+ definition: options.definition,
88
+ version: current === undefined ? 0 : current.version + 1,
89
+ input: current?.input ?? snapshot.input as FlowClosedValue,
90
+ config: current?.config ?? options.config,
91
+ state: durableSnapshot.vars as FlowClosedValue,
92
+ output: options.projectOutput?.(snapshot) ?? current?.output ?? null,
93
+ controllerSidecars: workCtrlControllerSidecars(durableSnapshot),
94
+ nodeSidecars: workCtrlNodeSidecars(durableSnapshot),
95
+ profile: {
96
+ kind: 'WorkCtrlFlow',
97
+ snapshot: durableSnapshot as unknown as FlowClosedObject,
98
+ },
99
+ };
100
+ await commitFlowRunCheckpoint(runtime, {
101
+ expectedVersion: current?.version ?? null,
102
+ checkpoint,
103
+ });
104
+ },
105
+ async remove(treeId) {
106
+ throw new FlowRunCheckpointTransitionError(
107
+ `Canonical FlowRunCheckpoint removal is not supported for ${treeId}.`,
108
+ );
109
+ },
110
+ };
111
+ }
112
+
113
+ export class FlowRunCheckpointVersionConflictError extends Error {
114
+ constructor(
115
+ public readonly expectedVersion: number | null,
116
+ public readonly actualVersion: number | null,
117
+ ) {
118
+ super(`Flow run checkpoint version conflict: expected ${String(expectedVersion)}, actual ${String(actualVersion)}`);
119
+ this.name = 'FlowRunCheckpointVersionConflictError';
120
+ }
121
+ }
122
+
123
+ export class FlowRunCheckpointTransitionError extends Error {
124
+ constructor(message: string) {
125
+ super(message);
126
+ this.name = 'FlowRunCheckpointTransitionError';
127
+ }
128
+ }
129
+
130
+ function checkpointKey(key: FlowRunCheckpointKey): string {
131
+ return JSON.stringify([key.instanceId, key.runId]);
132
+ }
133
+
134
+ function sameClosedValue(left: unknown, right: unknown): boolean {
135
+ return JSON.stringify(left) === JSON.stringify(right);
136
+ }
137
+
138
+ function checkpointTerminalStatus(checkpoint: AIWorkflowFlowRunCheckpoint): string | undefined {
139
+ if (checkpoint.profile.kind === 'WorkCtrlFlow' || checkpoint.profile.kind === 'AICtrlWorkflow') {
140
+ const status = (checkpoint.profile.snapshot as { readonly status?: unknown }).status;
141
+ return status === 'Completed' || status === 'Failed' ? status : undefined;
142
+ }
143
+ const status = checkpoint.controllerSidecars.status;
144
+ return status === 'Succeeded' || status === 'Failed' || status === 'Cancelled' ? status : undefined;
145
+ }
146
+
147
+ function isTerminalAIDataNodeResult(value: unknown): boolean {
148
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
149
+ const status = (value as { readonly status?: unknown }).status;
150
+ return status === 'Succeeded' || status === 'Failed' || status === 'Reused';
151
+ }
152
+
153
+ function assertAppendPrefix(name: string, before: readonly unknown[], after: readonly unknown[]): void {
154
+ if (after.length < before.length
155
+ || before.some((item, index) => !sameClosedValue(item, after[index]))) {
156
+ throw new FlowRunCheckpointTransitionError(`AI Data ${name} must preserve its accepted append-only prefix.`);
157
+ }
158
+ }
159
+
160
+ function validateAIDataInitialCheckpoint(checkpoint: AIWorkflowFlowRunCheckpoint): void {
161
+ if (checkpoint.profile.kind !== 'AIDataWorkflow') return;
162
+ const graph = checkpoint.profile.runGraph as unknown as {
163
+ readonly currentGeneration: number;
164
+ readonly declarationOrder: readonly string[];
165
+ readonly patchHistory: readonly unknown[];
166
+ readonly invalidations: readonly unknown[];
167
+ readonly binding: {
168
+ readonly definition: {
169
+ readonly declarationOrder: readonly string[];
170
+ readonly nodeById: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
171
+ };
172
+ };
173
+ readonly platformDefaultPolicy?: 'semantic-hash' | 'never';
174
+ readonly nodes: Readonly<Record<string, {
175
+ readonly generation: number;
176
+ readonly status: string;
177
+ readonly nodeType: string;
178
+ readonly config: Readonly<Record<string, unknown>>;
179
+ readonly reusePolicy: unknown;
180
+ readonly result?: unknown;
181
+ readonly semanticFingerprint?: unknown;
182
+ readonly invalidatedBy?: unknown;
183
+ }>>;
184
+ };
185
+ const definitionOrder = graph.binding.definition.declarationOrder;
186
+ if (graph.currentGeneration !== 0
187
+ || graph.patchHistory.length !== 0
188
+ || graph.invalidations.length !== 0
189
+ || !sameClosedValue(graph.declarationOrder, definitionOrder)
190
+ || Object.keys(graph.nodes).length !== definitionOrder.length) {
191
+ throw new FlowRunCheckpointTransitionError(
192
+ 'AI Data checkpoint version zero must be the complete generation-zero binding projection.',
193
+ );
194
+ }
195
+ for (const nodeId of definitionOrder) {
196
+ const node = graph.nodes[nodeId];
197
+ const definitionNode = graph.binding.definition.nodeById[nodeId];
198
+ if (!node || !definitionNode || node.generation !== 0 || node.status !== 'Pending'
199
+ || node.result !== undefined || node.semanticFingerprint !== undefined || node.invalidatedBy !== undefined) {
200
+ throw new FlowRunCheckpointTransitionError(
201
+ `AI Data checkpoint version zero node ${nodeId} must be a Pending generation-zero binding projection.`,
202
+ );
203
+ }
204
+ const config = node.config;
205
+ const expectedNodeType = config.node_type === 'manual' || config.nodeType === 'manual'
206
+ || definitionNode.type === 'manual'
207
+ ? 'manual'
208
+ : definitionNode.tag === 'SinkNode'
209
+ ? 'effect'
210
+ : definitionNode.tag === 'SubFlowNode'
211
+ ? 'subflow'
212
+ : 'data';
213
+ const explicitReuse = config.reuse_policy ?? config.reusePolicy;
214
+ const expectedReusePolicy = explicitReuse === 'semantic-hash' || explicitReuse === 'never'
215
+ ? { nodeType: expectedNodeType, policy: explicitReuse, source: 'node' }
216
+ : expectedNodeType !== 'manual' && graph.platformDefaultPolicy !== undefined
217
+ ? { nodeType: expectedNodeType, policy: graph.platformDefaultPolicy, source: 'platform-default' }
218
+ : {
219
+ nodeType: expectedNodeType,
220
+ policy: expectedNodeType === 'manual' ? 'never' : 'semantic-hash',
221
+ source: 'node-type-default',
222
+ };
223
+ if (node.nodeType !== expectedNodeType || !sameClosedValue(node.reusePolicy, expectedReusePolicy)) {
224
+ throw new FlowRunCheckpointTransitionError(
225
+ `AI Data checkpoint version zero node ${nodeId} has a non-canonical node type or reuse policy.`,
226
+ );
227
+ }
228
+ }
229
+ }
230
+
231
+ function validateAIDataProfileTransition(
232
+ current: AIWorkflowFlowRunCheckpoint & { readonly profile: { readonly kind: 'AIDataWorkflow'; readonly runGraph: FlowClosedObject } },
233
+ next: AIWorkflowFlowRunCheckpoint & { readonly profile: { readonly kind: 'AIDataWorkflow'; readonly runGraph: FlowClosedObject } },
234
+ ): void {
235
+ const before = current.profile.runGraph as unknown as {
236
+ readonly binding: unknown;
237
+ readonly platformDefaultPolicy?: 'semantic-hash' | 'never';
238
+ readonly currentGeneration: number;
239
+ readonly declarationOrder: readonly string[];
240
+ readonly patchHistory: readonly {
241
+ readonly generation: number;
242
+ readonly operations: readonly ({
243
+ readonly op: string;
244
+ readonly nodeId?: string;
245
+ readonly node?: Readonly<Record<string, unknown>> & { readonly id: string };
246
+ readonly changes?: Readonly<Record<string, unknown>>;
247
+ })[];
248
+ }[];
249
+ readonly invalidations: readonly { readonly generation: number; readonly nodeId: string }[];
250
+ readonly nodes: Readonly<Record<string, {
251
+ readonly id: string;
252
+ readonly tag: string;
253
+ readonly nodeType: string;
254
+ readonly generation: number;
255
+ readonly status: string;
256
+ readonly declarationIndex: number;
257
+ readonly inputs: unknown;
258
+ readonly outputs: unknown;
259
+ readonly config: Readonly<Record<string, unknown>>;
260
+ readonly dependsOn: unknown;
261
+ readonly dependents: unknown;
262
+ readonly reusePolicy: unknown;
263
+ readonly result?: unknown;
264
+ readonly semanticFingerprint?: unknown;
265
+ }>>;
266
+ };
267
+ const after = next.profile.runGraph as unknown as typeof before;
268
+ if (!sameClosedValue(before.binding, after.binding)) {
269
+ throw new FlowRunCheckpointTransitionError('AI Data binding is immutable after checkpoint version zero.');
270
+ }
271
+ if (before.platformDefaultPolicy !== after.platformDefaultPolicy) {
272
+ throw new FlowRunCheckpointTransitionError('AI Data platform default reuse policy is immutable after checkpoint version zero.');
273
+ }
274
+ if (after.currentGeneration < before.currentGeneration) {
275
+ throw new FlowRunCheckpointTransitionError('AI Data currentGeneration cannot decrease.');
276
+ }
277
+ assertAppendPrefix('patchHistory', before.patchHistory, after.patchHistory);
278
+ for (const [nodeId, node] of Object.entries(before.nodes)) {
279
+ const candidate = after.nodes[nodeId];
280
+ if (!candidate) {
281
+ throw new FlowRunCheckpointTransitionError(`AI Data accepted node ${nodeId} cannot disappear.`);
282
+ }
283
+ if (candidate.generation < node.generation) {
284
+ throw new FlowRunCheckpointTransitionError(
285
+ `AI Data node ${nodeId} generation cannot decrease from ${node.generation} to ${candidate.generation}.`,
286
+ );
287
+ }
288
+ }
289
+ const appendedPatches = after.patchHistory.slice(before.patchHistory.length);
290
+ const appendedInvalidations = after.invalidations.filter((fact) =>
291
+ !before.invalidations.some((accepted) => sameClosedValue(accepted, fact))
292
+ );
293
+ if (after.currentGeneration > before.currentGeneration
294
+ && appendedPatches.length === 0
295
+ && appendedInvalidations.length === 0) {
296
+ throw new FlowRunCheckpointTransitionError(
297
+ 'AI Data generation advancement requires an accepted patch or invalidation fact.',
298
+ );
299
+ }
300
+ for (const accepted of before.invalidations) {
301
+ if (after.invalidations.some((fact) => sameClosedValue(fact, accepted))) continue;
302
+ const replacement = after.invalidations.find((fact) => fact.nodeId === accepted.nodeId);
303
+ const target = after.nodes[accepted.nodeId];
304
+ if (!replacement && target?.status === 'Invalidated') {
305
+ throw new FlowRunCheckpointTransitionError(
306
+ `AI Data active invalidation ${accepted.nodeId} cannot disappear while its target remains Invalidated.`,
307
+ );
308
+ }
309
+ }
310
+ const addedNodeIds: string[] = [];
311
+ const explainedFields = new Map<string, Set<string>>();
312
+ const structuralFields = [
313
+ 'tag', 'nodeType', 'declarationIndex', 'inputs', 'outputs', 'config', 'dependsOn', 'reusePolicy',
314
+ ] as const;
315
+ const explain = (nodeId: string, fields: readonly string[]) => {
316
+ const accepted = explainedFields.get(nodeId) ?? new Set<string>();
317
+ for (const field of fields) accepted.add(field);
318
+ explainedFields.set(nodeId, accepted);
319
+ };
320
+ const derivedNodeType = (node: Readonly<Record<string, unknown>>): string => {
321
+ if (typeof node.nodeType === 'string') return node.nodeType;
322
+ const config = typeof node.config === 'object' && node.config !== null
323
+ ? node.config as Readonly<Record<string, unknown>>
324
+ : {};
325
+ if (config.node_type === 'manual' || config.nodeType === 'manual') return 'manual';
326
+ if (node.tag === 'SinkNode') return 'effect';
327
+ if (node.tag === 'SubFlowNode') return 'subflow';
328
+ return 'data';
329
+ };
330
+ const derivedReusePolicy = (
331
+ nodeType: string,
332
+ config: Readonly<Record<string, unknown>>,
333
+ ): Readonly<Record<string, unknown>> => {
334
+ const explicit = config.reuse_policy ?? config.reusePolicy;
335
+ if (explicit === 'semantic-hash' || explicit === 'never') {
336
+ return { nodeType, policy: explicit, source: 'node' };
337
+ }
338
+ if (nodeType !== 'manual' && after.platformDefaultPolicy !== undefined) {
339
+ return { nodeType, policy: after.platformDefaultPolicy, source: 'platform-default' };
340
+ }
341
+ return {
342
+ nodeType,
343
+ policy: nodeType === 'manual' ? 'never' : 'semantic-hash',
344
+ source: 'node-type-default',
345
+ };
346
+ };
347
+ for (const patch of appendedPatches) {
348
+ if (patch.generation !== after.currentGeneration) {
349
+ throw new FlowRunCheckpointTransitionError(
350
+ `AI Data appended patch generation ${patch.generation} must equal current generation ${after.currentGeneration}.`,
351
+ );
352
+ }
353
+ for (const operation of patch.operations) {
354
+ const nodeId = operation.op === 'add-node' ? operation.node?.id : operation.nodeId;
355
+ if (!nodeId || after.nodes[nodeId]?.generation !== patch.generation) {
356
+ throw new FlowRunCheckpointTransitionError(
357
+ `AI Data appended patch target ${String(nodeId)} must project generation ${patch.generation}.`,
358
+ );
359
+ }
360
+ const candidate = after.nodes[nodeId];
361
+ if (operation.op === 'add-node') {
362
+ if (!operation.node || before.nodes[nodeId]) {
363
+ throw new FlowRunCheckpointTransitionError(`AI Data add-node target ${nodeId} must be new.`);
364
+ }
365
+ const declarationIndex = before.declarationOrder.length + addedNodeIds.length;
366
+ const expectedNode = {
367
+ tag: operation.node.tag ?? 'TransformNode',
368
+ nodeType: derivedNodeType(operation.node),
369
+ declarationIndex: operation.node.declarationIndex ?? declarationIndex,
370
+ inputs: operation.node.inputs ?? {},
371
+ outputs: operation.node.outputs ?? [],
372
+ config: operation.node.config ?? {},
373
+ dependsOn: operation.node.dependsOn ?? [],
374
+ };
375
+ const expectedReusePolicy = operation.node.reusePolicy
376
+ ?? derivedReusePolicy(expectedNode.nodeType, expectedNode.config as Readonly<Record<string, unknown>>);
377
+ for (const [field, expected] of Object.entries({ ...expectedNode, reusePolicy: expectedReusePolicy })) {
378
+ if (!sameClosedValue(candidate[field as keyof typeof candidate], expected)) {
379
+ throw new FlowRunCheckpointTransitionError(`AI Data add-node ${nodeId} does not project declared ${field}.`);
380
+ }
381
+ }
382
+ addedNodeIds.push(nodeId);
383
+ continue;
384
+ }
385
+ const accepted = before.nodes[nodeId];
386
+ if (!accepted) {
387
+ throw new FlowRunCheckpointTransitionError(`AI Data ${operation.op} target ${nodeId} must already be accepted.`);
388
+ }
389
+ if (operation.op === 'remove-node') {
390
+ if (accepted.status === 'Removed') {
391
+ throw new FlowRunCheckpointTransitionError(`AI Data Removed tombstone ${nodeId} cannot be removed again.`);
392
+ }
393
+ if (candidate.status !== 'Removed'
394
+ || candidate.result !== undefined
395
+ || candidate.semanticFingerprint !== undefined) {
396
+ throw new FlowRunCheckpointTransitionError(`AI Data remove-node ${nodeId} must retain only a Removed tombstone without result facts.`);
397
+ }
398
+ continue;
399
+ }
400
+ if (operation.op !== 'update-node' || !operation.changes) {
401
+ throw new FlowRunCheckpointTransitionError(`AI Data patch operation ${operation.op} is unsupported.`);
402
+ }
403
+ for (const [field, expected] of Object.entries(operation.changes)) {
404
+ if (!sameClosedValue(candidate[field as keyof typeof candidate], expected)) {
405
+ throw new FlowRunCheckpointTransitionError(`AI Data update-node ${nodeId} does not project declared ${field}.`);
406
+ }
407
+ }
408
+ const changedFields = Object.keys(operation.changes);
409
+ if ((changedFields.includes('config') || changedFields.includes('nodeType'))
410
+ && !changedFields.includes('reusePolicy')) {
411
+ const expectedReusePolicy = derivedReusePolicy(candidate.nodeType, candidate.config);
412
+ if (!sameClosedValue(candidate.reusePolicy, expectedReusePolicy)) {
413
+ throw new FlowRunCheckpointTransitionError(`AI Data update-node ${nodeId} does not project its config-derived reuse policy.`);
414
+ }
415
+ changedFields.push('reusePolicy');
416
+ }
417
+ explain(nodeId, changedFields);
418
+ }
419
+ }
420
+ const expectedDeclarationOrder = [...before.declarationOrder, ...addedNodeIds];
421
+ if (!sameClosedValue(after.declarationOrder, expectedDeclarationOrder)) {
422
+ throw new FlowRunCheckpointTransitionError('AI Data declarationOrder may append only declared add-node targets.');
423
+ }
424
+ for (const [nodeId, accepted] of Object.entries(before.nodes)) {
425
+ const candidate = after.nodes[nodeId];
426
+ const allowed = explainedFields.get(nodeId) ?? new Set<string>();
427
+ for (const field of structuralFields) {
428
+ if (!sameClosedValue(accepted[field], candidate[field]) && !allowed.has(field)) {
429
+ throw new FlowRunCheckpointTransitionError(
430
+ `AI Data node ${nodeId} cannot change structural field ${field} without a matching update-node operation.`,
431
+ );
432
+ }
433
+ }
434
+ }
435
+ for (const nodeId of Object.keys(after.nodes)) {
436
+ if (!before.nodes[nodeId] && !addedNodeIds.includes(nodeId)) {
437
+ throw new FlowRunCheckpointTransitionError(`AI Data node ${nodeId} requires a matching add-node operation.`);
438
+ }
439
+ }
440
+ for (const invalidation of appendedInvalidations) {
441
+ if (invalidation.generation !== after.currentGeneration
442
+ || after.nodes[invalidation.nodeId]?.generation !== invalidation.generation
443
+ || after.nodes[invalidation.nodeId]?.status !== 'Invalidated'
444
+ || after.nodes[invalidation.nodeId]?.result !== undefined
445
+ || after.nodes[invalidation.nodeId]?.semanticFingerprint !== undefined) {
446
+ throw new FlowRunCheckpointTransitionError(
447
+ `AI Data appended invalidation target ${invalidation.nodeId} must project Invalidated at current generation ${after.currentGeneration} without a result or fingerprint.`,
448
+ );
449
+ }
450
+ }
451
+ for (const [nodeId, node] of Object.entries(before.nodes)) {
452
+ if (!node.result || !isTerminalAIDataNodeResult(node.result)) continue;
453
+ const candidate = after.nodes[nodeId];
454
+ if (!candidate || (candidate.generation === node.generation
455
+ && (!sameClosedValue(candidate.result, node.result)
456
+ || !sameClosedValue(candidate.semanticFingerprint, node.semanticFingerprint)))) {
457
+ throw new FlowRunCheckpointTransitionError(
458
+ `AI Data terminal result ${nodeId}@${node.generation} is immutable.`,
459
+ );
460
+ }
461
+ }
462
+ }
463
+
464
+ function validateAgentStateTransition(
465
+ current: AIWorkflowFlowRunCheckpoint,
466
+ next: AIWorkflowFlowRunCheckpoint,
467
+ ): void {
468
+ if ((current.profile.kind !== 'AICtrlWorkflow' && current.profile.kind !== 'AIDataWorkflow')
469
+ || (next.profile.kind !== 'AICtrlWorkflow' && next.profile.kind !== 'AIDataWorkflow')) return;
470
+ const before = current.profile.ai;
471
+ const after = next.profile.ai;
472
+ for (const [instanceId, instance] of Object.entries(before.instancesById)) {
473
+ if (!sameClosedValue(instance, after.instancesById[instanceId])) {
474
+ throw new FlowRunCheckpointTransitionError(`Accepted Agent instance ${instanceId} is immutable and cannot disappear.`);
475
+ }
476
+ }
477
+ for (const [name, instanceId] of Object.entries(before.instanceIdByName)) {
478
+ if (after.instanceIdByName[name] !== instanceId) {
479
+ throw new FlowRunCheckpointTransitionError(`Accepted Agent instance name ${name} is immutable and cannot be rebound.`);
480
+ }
481
+ }
482
+ for (const [invocationKey, receipt] of Object.entries(before.invocationsByKey)) {
483
+ const candidate = after.invocationsByKey[invocationKey];
484
+ if (!candidate) throw new FlowRunCheckpointTransitionError(`Accepted Agent invocation ${invocationKey} cannot disappear.`);
485
+ if (receipt.status === 'completed') {
486
+ if (!sameClosedValue(receipt, candidate)) throw new FlowRunCheckpointTransitionError(`Completed Agent invocation ${invocationKey} is immutable.`);
487
+ continue;
488
+ }
489
+ if (candidate.status === 'pending') {
490
+ if (!sameClosedValue(receipt, candidate)) throw new FlowRunCheckpointTransitionError(`Pending Agent invocation ${invocationKey} cannot rewrite its reservation.`);
491
+ continue;
492
+ }
493
+ const completionBase = {
494
+ ...candidate,
495
+ status: 'pending',
496
+ instanceId: receipt.instanceId,
497
+ output: undefined,
498
+ hostReceipt: undefined,
499
+ } as Record<string, unknown>;
500
+ delete completionBase.output;
501
+ delete completionBase.hostReceipt;
502
+ if (receipt.instanceId === undefined) delete completionBase.instanceId;
503
+ if (!sameClosedValue(receipt, completionBase)) {
504
+ throw new FlowRunCheckpointTransitionError(`Agent invocation ${invocationKey} completion rewrites its reservation.`);
505
+ }
506
+ }
507
+ for (const [invocationKey, receipt] of Object.entries(after.invocationsByKey)) {
508
+ if (!before.invocationsByKey[invocationKey] && receipt.status !== 'pending') {
509
+ throw new FlowRunCheckpointTransitionError(`New Agent invocation ${invocationKey} must first be durably pending.`);
510
+ }
511
+ }
512
+ }
513
+
514
+ /** Shared logical transition gate used by every canonical checkpoint owner. */
515
+ export function validateFlowRunCheckpointTransition(
516
+ current: AIWorkflowFlowRunCheckpoint | undefined,
517
+ next: AIWorkflowFlowRunCheckpoint,
518
+ ): void {
519
+ const requiredVersion = current === undefined ? 0 : current.version + 1;
520
+ if (next.version !== requiredVersion) {
521
+ throw new FlowRunCheckpointTransitionError(
522
+ `Checkpoint version must be ${requiredVersion}, got ${next.version}`,
523
+ );
524
+ }
525
+ if (!current) {
526
+ validateAIDataInitialCheckpoint(next);
527
+ return;
528
+ }
529
+ const immutableFacts: readonly [string, unknown, unknown][] = [
530
+ ['schemaVersion', current.schemaVersion, next.schemaVersion],
531
+ ['instanceId', current.instanceId, next.instanceId],
532
+ ['runId', current.runId, next.runId],
533
+ ['definition', current.definition, next.definition],
534
+ ['profile.kind', current.profile.kind, next.profile.kind],
535
+ ['input', current.input, next.input],
536
+ ['config', current.config, next.config],
537
+ ];
538
+ for (const [name, before, after] of immutableFacts) {
539
+ if (!sameClosedValue(before, after)) {
540
+ throw new FlowRunCheckpointTransitionError(`A run checkpoint cannot rewrite immutable ${name}.`);
541
+ }
542
+ }
543
+ const terminal = checkpointTerminalStatus(current);
544
+ if (terminal !== undefined) {
545
+ throw new FlowRunCheckpointTransitionError(
546
+ `Terminal checkpoint status ${terminal} does not accept another transition.`,
547
+ );
548
+ }
549
+ if (current.profile.kind === 'AIDataWorkflow' && next.profile.kind === 'AIDataWorkflow') {
550
+ validateAIDataProfileTransition(
551
+ current as AIWorkflowFlowRunCheckpoint & { readonly profile: { readonly kind: 'AIDataWorkflow'; readonly runGraph: FlowClosedObject } },
552
+ next as AIWorkflowFlowRunCheckpoint & { readonly profile: { readonly kind: 'AIDataWorkflow'; readonly runGraph: FlowClosedObject } },
553
+ );
554
+ }
555
+ validateAgentStateTransition(current, next);
556
+ }
557
+
558
+ /** In-memory semantic owner used to validate the port before physical CAS is introduced. */
559
+ export class InMemoryFlowRunCheckpointStore implements FlowRunCheckpointStore<FlowRunProfileState> {
560
+ readonly #checkpoints = new Map<string, AIWorkflowFlowRunCheckpoint>();
561
+
562
+ async load(key: FlowRunCheckpointKey): Promise<AIWorkflowFlowRunCheckpoint | undefined> {
563
+ return this.#checkpoints.get(checkpointKey(key));
564
+ }
565
+
566
+ async commit(input: FlowRunCheckpointCommit<FlowRunProfileState>): Promise<AIWorkflowFlowRunCheckpoint> {
567
+ const checkpoint = normalizeFlowRunCheckpoint(input.checkpoint);
568
+ const key = checkpointKey(checkpoint);
569
+ const current = this.#checkpoints.get(key);
570
+ const actualVersion = current?.version ?? null;
571
+ if (input.expectedVersion !== actualVersion) {
572
+ throw new FlowRunCheckpointVersionConflictError(input.expectedVersion, actualVersion);
573
+ }
574
+ validateFlowRunCheckpointTransition(current, checkpoint);
575
+ this.#checkpoints.set(key, checkpoint);
576
+ return checkpoint;
577
+ }
578
+ }
579
+
580
+ export async function loadFlowRunCheckpoint(
581
+ runtime: FlowRunCheckpointRuntime,
582
+ input: FlowRunCheckpointKey,
583
+ ): Promise<AIWorkflowFlowRunCheckpoint | undefined> {
584
+ return runtime.checkpointStore.load(input) as Promise<AIWorkflowFlowRunCheckpoint | undefined>;
585
+ }
586
+
587
+ export async function commitFlowRunCheckpoint(
588
+ runtime: FlowRunCheckpointRuntime,
589
+ input: FlowRunCheckpointCommit<FlowRunProfileState>,
590
+ ): Promise<AIWorkflowFlowRunCheckpoint> {
591
+ return runtime.checkpointStore.commit(input) as Promise<AIWorkflowFlowRunCheckpoint>;
592
+ }
593
+
594
+ /** Detached readonly projection; mutating it cannot affect the canonical store. */
595
+ export async function projectFlowRunCheckpoint(
596
+ runtime: FlowRunCheckpointRuntime,
597
+ input: FlowRunCheckpointKey,
598
+ ): Promise<AIWorkflowFlowRunCheckpoint | undefined> {
599
+ const checkpoint = await loadFlowRunCheckpoint(runtime, input);
600
+ return checkpoint === undefined ? undefined : normalizeFlowRunCheckpoint(checkpoint);
601
+ }
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  type AIWorkflowReusePolicy,
28
28
  type AIWorkflowRuntimeRoots,
29
29
  type AIWorkflowRuntimeContext,
30
- type AIWorkflowStateStore,
30
+ type AIWorkflowStateProjection,
31
31
  type AIWorkflowSubstrate,
32
32
  type ProjectAIWorkflowAppBundlesOptions,
33
33
  } from 'ai-workflow-contract';
@@ -36,6 +36,36 @@ import { AIWorkflowFoundationError, AIWorkflowResourceProjectionError } from './
36
36
 
37
37
  export { AIWorkflowFoundationError, AIWorkflowResourceProjectionError } from './errors.js';
38
38
  export { projectAIWorkflowAgentResources } from './agent-resources.js';
39
+ export {
40
+ FlowRunContractValidationError,
41
+ normalizeAIWorkflowProfileDurableState,
42
+ normalizeFlowClosedObject,
43
+ normalizeFlowClosedValue,
44
+ normalizeFlowInstanceDescriptor,
45
+ normalizeFlowRunCheckpoint,
46
+ } from './run-checkpoint.js';
47
+ export {
48
+ AIAgentInvocationError,
49
+ bindAIAgentProcessors,
50
+ normalizeAIAgentEffectConfig,
51
+ normalizeAIAgentInvocation,
52
+ normalizeAIAgentSelector,
53
+ runAgent,
54
+ runTargetedAgent,
55
+ } from './agent-invocation.js';
56
+ export { assertAIAgentNodeTaskBinding, upgradeLegacyAIWorkflowProfileDurableState } from './agent-state.js';
57
+ export type { AIAgentNodeBindingIdentity } from './agent-state.js';
58
+ export {
59
+ FlowRunCheckpointTransitionError,
60
+ FlowRunCheckpointVersionConflictError,
61
+ InMemoryFlowRunCheckpointStore,
62
+ createWorkCtrlFlowCheckpointStore,
63
+ projectWorkCtrlSnapshotToCheckpointData,
64
+ commitFlowRunCheckpoint,
65
+ loadFlowRunCheckpoint,
66
+ projectFlowRunCheckpoint,
67
+ } from './checkpoint-store.js';
68
+ export type { WorkCtrlFlowCheckpointStoreOptions } from './checkpoint-store.js';
39
69
 
40
70
  export class AIWorkflowEffectHandlerNotFoundError extends AIWorkflowFoundationError {
41
71
  constructor(
@@ -353,8 +383,8 @@ export function resolveAIWorkflowReusePolicy(
353
383
  }
354
384
 
355
385
  export function createAIWorkflowRuntimeContext<
356
- Effects extends AIWorkflowEffectProvider,
357
- Store extends AIWorkflowStateStore,
386
+ Effects extends AIWorkflowEffectProvider | import('ai-workflow-contract').AIWorkflowAuthoredEffects,
387
+ Store extends AIWorkflowStateProjection,
358
388
  >(
359
389
  context: AIWorkflowRuntimeContext<Effects, Store>,
360
390
  ): AIWorkflowRuntimeContext<Effects, Store> {
@@ -404,7 +434,7 @@ export function createAIWorkflowEffectHandlerRegistry(
404
434
  };
405
435
  },
406
436
  listOperations() {
407
- return [...handlers.keys()].sort();
437
+ return [...handlers.keys()].sort(compareCodeUnits);
408
438
  },
409
439
  registerHandler(operation, handler) {
410
440
  if (!operation) {