ai-data-workflow-logic 0.1.4 → 0.1.6

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 CHANGED
@@ -1,26 +1,28 @@
1
1
  {
2
2
  "name": "ai-data-workflow-logic",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "EagerDataFlow-backed AI Data Workflow RunGraph helpers",
6
6
  "type": "module",
7
7
  "main": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./browser": "./src/browser.ts"
10
+ "./browser": "./src/browser.ts",
11
+ "./filesystem": "./src/filesystem.ts"
11
12
  },
12
13
  "dependencies": {
13
- "ai-data-workflow-contract": "0.1.0",
14
- "ai-workflow-contract": "0.1.0",
15
- "ai-workflow-logic": "0.1.0",
16
- "eager-data-flow-contract": "0.1.0",
17
- "eager-data-flow-logic": "^0.1.1"
14
+ "ai-data-workflow-contract": "0.1.2",
15
+ "ai-workflow-contract": "0.1.5",
16
+ "ai-workflow-logic": "0.1.5",
17
+ "eager-data-flow-contract": "0.1.2",
18
+ "eager-data-flow-logic": "0.1.3",
19
+ "flow-step-space-logic": "0.1.0",
20
+ "work-ctrl-flow-logic": "0.1.2"
18
21
  },
19
22
  "files": [
20
23
  "src"
21
24
  ],
22
25
  "publishConfig": {
23
- "access": "public",
24
- "registry": "https://registry.npmjs.com"
26
+ "access": "public"
25
27
  }
26
28
  }
@@ -0,0 +1,446 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ AIDataWorkflowRunCheckpoint,
5
+ AIDataWorkflowRunGraph,
6
+ } from 'ai-data-workflow-contract';
7
+ import type {
8
+ AIDataWorkflowRuntime,
9
+ AIAgentHostEffectPort,
10
+ FrozenAIAgentTaskBinding,
11
+ AIWorkflowProfileDurableState,
12
+ AIWorkflowReusePolicy,
13
+ FlowClosedValue,
14
+ } from 'ai-workflow-contract';
15
+ import type {
16
+ EagerDataFlowModuleImporter,
17
+ } from 'eager-data-flow-logic';
18
+ import type { EagerDataFlowDurableNodeState } from 'eager-data-flow-contract';
19
+ import {
20
+ createFilesystemCodeResolver,
21
+ materializeEagerDataFlowPlan,
22
+ } from 'eager-data-flow-logic';
23
+ import { assembleDefinitionStepProfileDirectory } from 'flow-step-space-logic/filesystem';
24
+ import { FilesystemFlowRunCheckpointStore } from 'ai-workflow-logic/checkpoint-filesystem';
25
+ import {
26
+ normalizeAIWorkflowProfileDurableState,
27
+ normalizeFlowClosedObject,
28
+ normalizeFlowClosedValue,
29
+ } from 'ai-workflow-logic';
30
+ import {
31
+ loadMaterializedFlowInstance,
32
+ materializeFlowInstance,
33
+ type FlowInstanceSourceIdentity,
34
+ } from 'work-ctrl-flow-logic';
35
+ import {
36
+ commitAIDataWorkflowCheckpoint,
37
+ createAIDataWorkflowCheckpoint,
38
+ createAIDataWorkflowRunGraph,
39
+ createAIDataWorkflowAgentNodeRuntimeBinder,
40
+ loadAIDataWorkflowCheckpoint,
41
+ loadAIDataWorkflowSources,
42
+ recordAIDataWorkflowNodeResult,
43
+ } from './index.js';
44
+
45
+ export interface MaterializedAIDataWorkflowRuntime {
46
+ readonly supportRoot: string;
47
+ readonly workflow: AIDataWorkflowRuntime;
48
+ readonly importModule: EagerDataFlowModuleImporter;
49
+ readonly agentEffects?: AIAgentHostEffectPort;
50
+ readonly agentTaskProofs?: Readonly<Record<string, FrozenAIAgentTaskBinding>>;
51
+ readonly checkpointObserver?: (event: {
52
+ readonly checkpoint: AIDataWorkflowRunCheckpoint;
53
+ readonly nodeId: string;
54
+ readonly phase: 'before' | 'after' | 'failed';
55
+ }) => void | Promise<void>;
56
+ }
57
+
58
+ export interface StartMaterializedAIDataWorkflowInput {
59
+ readonly instanceId: string;
60
+ readonly runId: string;
61
+ readonly bundleDir?: string;
62
+ readonly source?: FlowInstanceSourceIdentity;
63
+ readonly input: Readonly<Record<string, unknown>>;
64
+ }
65
+
66
+ export interface StartMaterializedAIDataWorkflowConfig {
67
+ readonly checkpointConfig: FlowClosedValue;
68
+ readonly platformDefaultPolicy?: AIWorkflowReusePolicy;
69
+ readonly initialState?: FlowClosedValue;
70
+ readonly durableAI: AIWorkflowProfileDurableState;
71
+ }
72
+
73
+ export interface MaterializedAIDataWorkflowResult {
74
+ readonly checkpoint: AIDataWorkflowRunCheckpoint;
75
+ readonly graph: AIDataWorkflowRunGraph;
76
+ }
77
+
78
+ export interface ContinueMaterializedAIDataWorkflowInput {
79
+ readonly instanceId: string;
80
+ readonly runId: string;
81
+ }
82
+
83
+ export type ContinueMaterializedAIDataWorkflowConfig = Readonly<Record<never, never>>;
84
+
85
+ /** Runtime-first canonical Data execution from a frozen instance. */
86
+ export async function startMaterializedAIDataWorkflow(
87
+ runtime: MaterializedAIDataWorkflowRuntime,
88
+ input: StartMaterializedAIDataWorkflowInput,
89
+ config: StartMaterializedAIDataWorkflowConfig,
90
+ ): Promise<MaterializedAIDataWorkflowResult> {
91
+ requireAgentTaskBindings(runtime);
92
+ const instance = ensureAIDataInstance(runtime, input);
93
+ const binding = loadAIDataBinding(instance.definitionDir);
94
+ const checkpointStore = new FilesystemFlowRunCheckpointStore({ storeRoot: runtime.supportRoot });
95
+ const checkpointRuntime = { checkpointStore };
96
+ const existing = await loadAIDataWorkflowCheckpoint(checkpointRuntime, input);
97
+ if (existing) {
98
+ assertStartMatchesCheckpoint(existing, input, config);
99
+ return executeAIDataCheckpoint(runtime, binding, existing);
100
+ }
101
+ const graph = createAIDataWorkflowRunGraph({
102
+ binding,
103
+ runId: input.runId,
104
+ platformDefaultPolicy: config.platformDefaultPolicy,
105
+ });
106
+ const initialState = { status: 'Pending', generation: graph.currentGeneration } as const;
107
+ if (config.initialState !== undefined
108
+ && JSON.stringify(normalizeFlowClosedValue(config.initialState, 'aiData.start.state')) !== JSON.stringify(initialState)) {
109
+ throw new Error('AI Data Workflow initialState must equal the canonical Pending RunGraph projection.');
110
+ }
111
+ const v0 = await createAIDataWorkflowCheckpoint(checkpointRuntime, {
112
+ instanceId: input.instanceId,
113
+ definition: instance.descriptor.definition,
114
+ graph: durableGraph(graph),
115
+ input: input.input as FlowClosedValue,
116
+ config: config.checkpointConfig,
117
+ state: initialState,
118
+ output: null,
119
+ controllerSidecars: { status: 'Pending' },
120
+ nodeSidecars: nodeStatuses(graph),
121
+ ai: config.durableAI,
122
+ });
123
+ return executeAIDataCheckpoint(runtime, binding, v0);
124
+ }
125
+
126
+ /** Runtime-first continuation from a checkpoint reconstructed in a fresh process. */
127
+ export async function continueMaterializedAIDataWorkflow(
128
+ runtime: MaterializedAIDataWorkflowRuntime,
129
+ input: ContinueMaterializedAIDataWorkflowInput,
130
+ _config: ContinueMaterializedAIDataWorkflowConfig,
131
+ ): Promise<MaterializedAIDataWorkflowResult> {
132
+ requireAgentTaskBindings(runtime);
133
+ const instanceDir = path.join(runtime.supportRoot, 'instances', input.instanceId);
134
+ const instance = loadMaterializedFlowInstance(instanceDir, input.instanceId, validateAIDataDefinition);
135
+ const binding = loadAIDataBinding(instance.definitionDir);
136
+ const checkpoint = await loadAIDataWorkflowCheckpoint(
137
+ { checkpointStore: new FilesystemFlowRunCheckpointStore({ storeRoot: runtime.supportRoot }) },
138
+ input,
139
+ );
140
+ if (!checkpoint) throw new Error(`AI Data Workflow run ${input.runId} does not have a canonical checkpoint.`);
141
+ return executeAIDataCheckpoint(runtime, binding, checkpoint);
142
+ }
143
+
144
+ function requireAgentTaskBindings(runtime: MaterializedAIDataWorkflowRuntime): void {
145
+ if (runtime.agentEffects && (!runtime.agentTaskProofs || Object.keys(runtime.agentTaskProofs).length === 0)) {
146
+ throw new Error('AI_AGENT_TASK_PROOF_REQUIRED: materialized AI Data Agent effects require run-freeze task proofs.');
147
+ }
148
+ }
149
+
150
+ async function executeAIDataCheckpoint(
151
+ runtime: MaterializedAIDataWorkflowRuntime,
152
+ binding: ReturnType<typeof loadAIDataBinding>,
153
+ checkpoint: AIDataWorkflowRunCheckpoint,
154
+ ): Promise<MaterializedAIDataWorkflowResult> {
155
+ let accepted = checkpoint;
156
+ let graph: AIDataWorkflowRunGraph = {
157
+ ...checkpoint.profile.runGraph,
158
+ binding,
159
+ };
160
+ const status = checkpointStatus(checkpoint);
161
+ if (status === 'Succeeded' || status === 'Failed') return { checkpoint, graph };
162
+ if (status !== 'Pending' && status !== 'Running') {
163
+ throw new Error(`AI Data Workflow checkpoint ${checkpoint.runId} has unsupported continuation status ${String(status)}.`);
164
+ }
165
+ if (typeof checkpoint.input !== 'object' || checkpoint.input === null || Array.isArray(checkpoint.input)) {
166
+ throw new Error(`AI Data Workflow checkpoint ${checkpoint.runId} input must be a closed object.`);
167
+ }
168
+ const invocationInput = checkpoint.input as Readonly<Record<string, FlowClosedValue>>;
169
+ const checkpointRuntime = {
170
+ checkpointStore: new FilesystemFlowRunCheckpointStore({ storeRoot: runtime.supportRoot }),
171
+ };
172
+ let transitionTail = Promise.resolve();
173
+ const transition = <T>(operation: () => Promise<T>): Promise<T> => {
174
+ const result = transitionTail.then(operation);
175
+ transitionTail = result.then(() => undefined, () => undefined);
176
+ return result;
177
+ };
178
+ const commitGraph = (
179
+ updateGraph: (current: AIDataWorkflowRunGraph) => AIDataWorkflowRunGraph,
180
+ nodeId: string,
181
+ phase: 'before' | 'after' | 'failed',
182
+ ): Promise<void> => transition(async () => {
183
+ const owner = await loadAIDataWorkflowCheckpoint(checkpointRuntime, {
184
+ instanceId: checkpoint.instanceId,
185
+ runId: checkpoint.runId,
186
+ });
187
+ if (!owner) throw new Error(`AI Data Workflow checkpoint ${checkpoint.runId} disappeared during node transition.`);
188
+ accepted = owner;
189
+ graph = { ...owner.profile.runGraph, binding };
190
+ const nextGraph = updateGraph(graph);
191
+ const nextStatus = graphCheckpointStatus(nextGraph);
192
+ accepted = await commitAIDataWorkflowCheckpoint(checkpointRuntime, {
193
+ expectedVersion: accepted.version,
194
+ checkpoint: {
195
+ ...accepted,
196
+ version: accepted.version + 1,
197
+ state: { status: nextStatus, generation: nextGraph.currentGeneration },
198
+ output: graphCheckpointOutput(nextGraph, nextStatus),
199
+ controllerSidecars: { status: nextStatus },
200
+ nodeSidecars: nodeStatuses(nextGraph),
201
+ profile: { ...accepted.profile, runGraph: durableGraph(nextGraph) },
202
+ },
203
+ });
204
+ graph = { ...accepted.profile.runGraph, binding };
205
+ if (runtime.checkpointObserver) {
206
+ try {
207
+ await runtime.checkpointObserver({ checkpoint: accepted, nodeId, phase });
208
+ } catch (cause) {
209
+ throw new AIDataCheckpointObserverError(cause);
210
+ }
211
+ }
212
+ });
213
+ let output: unknown;
214
+ try {
215
+ const executable = await materializeEagerDataFlowPlan(binding.definition, {
216
+ resolveCode: createFilesystemCodeResolver(runtime.importModule),
217
+ });
218
+ output = await executable.invoke(runtime.workflow, invocationInput, {
219
+ completedNodes: completedEagerNodes(graph),
220
+ ...(runtime.agentEffects ? {
221
+ bindNode: ((binder) => (nodeRuntime: unknown, identity: { readonly nodeId: string }) => binder(
222
+ nodeRuntime as AIDataWorkflowRuntime,
223
+ identity,
224
+ ))(createAIDataWorkflowAgentNodeRuntimeBinder({
225
+ flowInstanceId: checkpoint.instanceId,
226
+ runId: checkpoint.runId,
227
+ checkpointRuntime,
228
+ effects: runtime.agentEffects,
229
+ workflowRef: `resource://${binding.definition.fqn}`,
230
+ taskProofs: runtime.agentTaskProofs ?? {},
231
+ generationForNode: (nodeId) => graph.nodes[nodeId]?.generation ?? 0,
232
+ })),
233
+ } : {}),
234
+ onNodeStart: (nodeId) => {
235
+ return commitGraph((current) => {
236
+ const node = current.nodes[nodeId];
237
+ if (!node) throw new Error(`AI Data Workflow node ${nodeId} does not exist in the durable graph.`);
238
+ return recordAIDataWorkflowNodeResult(current, nodeId, {
239
+ nodeId,
240
+ generation: node.generation,
241
+ status: 'Running',
242
+ });
243
+ }, nodeId, 'before');
244
+ },
245
+ onNodeSuccess: (settlement) => {
246
+ return commitGraph((current) => {
247
+ const node = current.nodes[settlement.nodeId];
248
+ if (!node) throw new Error(`AI Data Workflow node ${settlement.nodeId} does not exist in the durable graph.`);
249
+ return recordAIDataWorkflowNodeResult(current, settlement.nodeId, {
250
+ nodeId: settlement.nodeId,
251
+ generation: node.generation,
252
+ status: 'Succeeded',
253
+ ...(settlement.output !== undefined ? { output: settlement.output } : {}),
254
+ ...(settlement.returned !== undefined ? { output: settlement.returned } : {}),
255
+ });
256
+ }, settlement.nodeId, 'after');
257
+ },
258
+ onNodeFailure: ({ nodeId }) => {
259
+ return commitGraph((current) => {
260
+ const node = current.nodes[nodeId];
261
+ if (!node) throw new Error(`AI Data Workflow node ${nodeId} does not exist in the durable graph.`);
262
+ return recordAIDataWorkflowNodeResult(current, nodeId, {
263
+ nodeId,
264
+ generation: node.generation,
265
+ status: 'Failed',
266
+ });
267
+ }, nodeId, 'failed');
268
+ },
269
+ });
270
+ } catch (error) {
271
+ const observerError = findCheckpointObserverError(error);
272
+ if (observerError) throw observerError.cause;
273
+ throw error;
274
+ }
275
+ if (checkpointStatus(accepted) !== 'Succeeded') {
276
+ throw new Error(`AI Data Workflow ${checkpoint.runId} completed without a terminal RunGraph projection.`);
277
+ }
278
+ if (JSON.stringify(accepted.output) !== JSON.stringify(normalizeFlowClosedValue(output, 'aiData.execution.output'))) {
279
+ throw new Error(`AI Data Workflow ${checkpoint.runId} Return output does not match its durable RunGraph result.`);
280
+ }
281
+ return { checkpoint: accepted, graph };
282
+ }
283
+
284
+ class AIDataCheckpointObserverError extends Error {
285
+ constructor(public override readonly cause: unknown) {
286
+ super('AI Data checkpoint observer interrupted execution.');
287
+ this.name = 'AIDataCheckpointObserverError';
288
+ }
289
+ }
290
+
291
+ function findCheckpointObserverError(value: unknown): AIDataCheckpointObserverError | undefined {
292
+ if (value instanceof AIDataCheckpointObserverError) return value;
293
+ if (typeof value !== 'object' || value === null || !('cause' in value)) return undefined;
294
+ return findCheckpointObserverError(value.cause);
295
+ }
296
+
297
+ function completedEagerNodes(
298
+ graph: AIDataWorkflowRunGraph,
299
+ ): Readonly<Record<string, EagerDataFlowDurableNodeState>> {
300
+ return Object.fromEntries(graph.declarationOrder.flatMap((nodeId) => {
301
+ const node = graph.nodes[nodeId];
302
+ if (!node || (node.status !== 'Succeeded' && node.status !== 'Reused') || !node.result) return [];
303
+ const state: EagerDataFlowDurableNodeState = {
304
+ nodeId,
305
+ ...(node.tag === 'ReturnNode'
306
+ ? { returned: node.result.output as Readonly<Record<string, unknown>> }
307
+ : node.tag === 'SinkNode'
308
+ ? {}
309
+ : { output: node.result.output as Readonly<Record<string, unknown>> }),
310
+ };
311
+ return [[nodeId, state] as const];
312
+ }));
313
+ }
314
+
315
+ /** Fresh reconstruction reads only the frozen instance and canonical checkpoint. */
316
+ export async function loadMaterializedAIDataWorkflowRun(
317
+ runtime: MaterializedAIDataWorkflowRuntime,
318
+ input: Pick<StartMaterializedAIDataWorkflowInput, 'instanceId' | 'runId'>,
319
+ ): Promise<MaterializedAIDataWorkflowResult | undefined> {
320
+ const instanceDir = path.join(runtime.supportRoot, 'instances', input.instanceId);
321
+ const instance = loadMaterializedFlowInstance(instanceDir, input.instanceId, validateAIDataDefinition);
322
+ const binding = loadAIDataBinding(instance.definitionDir);
323
+ const checkpoint = await loadAIDataWorkflowCheckpoint(
324
+ { checkpointStore: new FilesystemFlowRunCheckpointStore({ storeRoot: runtime.supportRoot }) },
325
+ input,
326
+ );
327
+ if (!checkpoint) return undefined;
328
+ return {
329
+ checkpoint,
330
+ graph: {
331
+ ...checkpoint.profile.runGraph,
332
+ binding,
333
+ },
334
+ };
335
+ }
336
+
337
+ function ensureAIDataInstance(
338
+ runtime: MaterializedAIDataWorkflowRuntime,
339
+ input: StartMaterializedAIDataWorkflowInput,
340
+ ) {
341
+ const instancesDir = path.join(runtime.supportRoot, 'instances');
342
+ const instanceDir = path.join(instancesDir, input.instanceId);
343
+ if (fs.existsSync(instanceDir)) {
344
+ return loadMaterializedFlowInstance(instanceDir, input.instanceId, validateAIDataDefinition);
345
+ }
346
+ if (!input.bundleDir || !input.source) {
347
+ if (input.bundleDir || input.source) throw new Error('bundleDir and source identity must be provided together.');
348
+ throw new Error('bundleDir and source identity are required for first admission.');
349
+ }
350
+ return materializeFlowInstance({
351
+ definitionDir: input.bundleDir,
352
+ instancesDir,
353
+ instanceId: input.instanceId,
354
+ source: input.source,
355
+ validateDefinition: validateAIDataDefinition,
356
+ });
357
+ }
358
+
359
+ function validateAIDataDefinition(definitionDir: string): void {
360
+ loadAIDataBinding(definitionDir);
361
+ }
362
+
363
+ function loadAIDataBinding(definitionDir: string) {
364
+ const assembled = assembleDefinitionStepProfileDirectory(definitionDir, 'AIDataWorkflow');
365
+ if (assembled.diagnostics.length > 0) {
366
+ throw new Error(`AIDataWorkflow StepSpace is invalid: ${assembled.diagnostics.map((item) => item.code).join(', ')}`);
367
+ }
368
+ const sources = Object.fromEntries((assembled.sources as readonly ({ readonly ref: string; readonly content: string } | { readonly name: string; readonly content: string })[])
369
+ .map((source) => ['name' in source ? source.name : source.ref, source.content]));
370
+ const loaded = loadAIDataWorkflowSources(sources, { baseUri: definitionDir });
371
+ if (!loaded.binding || loaded.diagnostics.length > 0) {
372
+ throw new Error(`AIDataWorkflow definition is invalid: ${loaded.diagnostics.map((item) => item.code).join(', ')}`);
373
+ }
374
+ return assembled.forest
375
+ ? { ...loaded.binding, definition: { ...loaded.binding.definition, definitionStepForest: assembled.forest } }
376
+ : loaded.binding;
377
+ }
378
+
379
+ function durableGraph(graph: AIDataWorkflowRunGraph): AIDataWorkflowRunGraph {
380
+ const { baseUri: _baseUri, ...definition } = graph.binding.definition;
381
+ return {
382
+ ...graph,
383
+ binding: { ...graph.binding, definition },
384
+ };
385
+ }
386
+
387
+ function nodeStatuses(graph: AIDataWorkflowRunGraph) {
388
+ return Object.fromEntries(
389
+ graph.declarationOrder.map((nodeId) => [nodeId, {
390
+ status: graph.nodes[nodeId].status,
391
+ generation: graph.nodes[nodeId].generation,
392
+ }]),
393
+ );
394
+ }
395
+
396
+ function graphCheckpointStatus(
397
+ graph: AIDataWorkflowRunGraph,
398
+ ): 'Pending' | 'Running' | 'Succeeded' | 'Failed' {
399
+ const nodes = Object.values(graph.nodes).filter((node) => node.status !== 'Removed');
400
+ if (nodes.some((node) => node.status === 'Failed')) return 'Failed';
401
+ if (nodes.some((node) => node.status === 'Running')) return 'Running';
402
+ if (nodes.length > 0 && nodes.every((node) => node.status === 'Succeeded' || node.status === 'Reused')) {
403
+ return 'Succeeded';
404
+ }
405
+ return 'Pending';
406
+ }
407
+
408
+ function graphCheckpointOutput(
409
+ graph: AIDataWorkflowRunGraph,
410
+ status: ReturnType<typeof graphCheckpointStatus>,
411
+ ): FlowClosedValue {
412
+ if (status !== 'Succeeded') return null;
413
+ const returnNode = graph.declarationOrder
414
+ .map((nodeId) => graph.nodes[nodeId])
415
+ .find((node) => node?.tag === 'ReturnNode');
416
+ if (!returnNode?.result || returnNode.result.output === undefined) {
417
+ throw new Error(`AI Data Workflow ${graph.runId} terminal RunGraph has no Return result.`);
418
+ }
419
+ return normalizeFlowClosedValue(returnNode.result.output, 'aiData.execution.output');
420
+ }
421
+
422
+ function checkpointStatus(checkpoint: AIDataWorkflowRunCheckpoint): unknown {
423
+ return checkpoint.controllerSidecars.status;
424
+ }
425
+
426
+ function assertStartMatchesCheckpoint(
427
+ checkpoint: AIDataWorkflowRunCheckpoint,
428
+ input: StartMaterializedAIDataWorkflowInput,
429
+ config: StartMaterializedAIDataWorkflowConfig,
430
+ ): void {
431
+ const normalizedInput = normalizeFlowClosedObject(input.input, 'aiData.start.input');
432
+ const normalizedConfig = normalizeFlowClosedValue(config.checkpointConfig, 'aiData.start.config');
433
+ if (JSON.stringify(normalizedInput) !== JSON.stringify(checkpoint.input)
434
+ || JSON.stringify(normalizedConfig) !== JSON.stringify(checkpoint.config)
435
+ || config.platformDefaultPolicy !== checkpoint.profile.runGraph.platformDefaultPolicy) {
436
+ throw new Error(`AI Data Workflow run ${input.runId} is already bound to different start facts.`);
437
+ }
438
+ if (checkpointStatus(checkpoint) === 'Pending') {
439
+ const normalizedAI = normalizeAIWorkflowProfileDurableState(config.durableAI, 'aiData.start.ai');
440
+ if ((config.initialState !== undefined
441
+ && JSON.stringify(normalizeFlowClosedValue(config.initialState, 'aiData.start.state')) !== JSON.stringify(checkpoint.state))
442
+ || JSON.stringify(normalizedAI) !== JSON.stringify(checkpoint.profile.ai)) {
443
+ throw new Error(`AI Data Workflow run ${input.runId} has different pending initialization facts.`);
444
+ }
445
+ }
446
+ }
package/src/index.ts CHANGED
@@ -7,13 +7,18 @@ import type {
7
7
  AIDataWorkflowNodeTag,
8
8
  AIDataWorkflowReuseCandidate,
9
9
  AIDataWorkflowRunGraph,
10
+ AIDataWorkflowRunCheckpoint,
10
11
  AIDataWorkflowRunNode,
11
12
  AIDataWorkflowRunNodeStatus,
12
13
  AIDataWorkflowSemanticInput,
13
14
  } from 'ai-data-workflow-contract';
14
15
  import type {
15
16
  AIDataWorkflowDefinitionBinding,
17
+ AIDataWorkflowRunProfileState,
16
18
  AIDataWorkflowRuntime,
19
+ AIAgentHostEffectPort,
20
+ FrozenAIAgentTaskBinding,
21
+ AIWorkflowAuthoredEffects,
17
22
  AIWorkflowDefinitionRef,
18
23
  AIWorkflowEffectProvider,
19
24
  AIWorkflowEffectiveReusePolicy,
@@ -24,8 +29,16 @@ import type {
24
29
  AIWorkflowRunState,
25
30
  AIWorkflowRunStatus,
26
31
  AIWorkflowRuntimeContext,
32
+ AIWorkflowProfileDurableState,
27
33
  AIWorkflowSemanticFingerprint,
28
- AIWorkflowStateStore,
34
+ AIWorkflowStateProjection,
35
+ FlowClosedObject,
36
+ FlowClosedValue,
37
+ FlowDefinitionArtifactIdentity,
38
+ FlowRunCheckpointCommit,
39
+ FlowRunCheckpointKey,
40
+ FlowRunCheckpointRuntime,
41
+ FlowRunProfileState,
29
42
  } from 'ai-workflow-contract';
30
43
  import type {
31
44
  EagerDataFlowAuthoringPlan,
@@ -38,9 +51,76 @@ import type {
38
51
  import { loadEagerDataFlowProfileSources } from 'eager-data-flow-logic';
39
52
  import {
40
53
  createAIWorkflowRuntimeContext,
54
+ commitFlowRunCheckpoint,
41
55
  defineAIDataWorkflow,
56
+ loadFlowRunCheckpoint,
42
57
  resolveAIWorkflowReusePolicy,
58
+ bindAIAgentProcessors,
43
59
  } from 'ai-workflow-logic';
60
+ import { assertFrozenAIAgentTaskBinding } from 'ai-workflow-logic/run-freeze';
61
+
62
+ export interface CreateAIDataWorkflowCheckpointInput {
63
+ readonly instanceId: string;
64
+ readonly definition: FlowDefinitionArtifactIdentity;
65
+ readonly graph: AIDataWorkflowRunGraph;
66
+ readonly input: FlowClosedValue;
67
+ readonly config: FlowClosedValue;
68
+ readonly state: FlowClosedValue;
69
+ readonly output: FlowClosedValue;
70
+ readonly controllerSidecars: FlowClosedObject;
71
+ readonly nodeSidecars: FlowClosedObject;
72
+ readonly ai: AIWorkflowProfileDurableState;
73
+ }
74
+
75
+ /** Creates the first complete Data checkpoint as one runtime-owned transition. */
76
+ export function createAIDataWorkflowCheckpoint(
77
+ runtime: FlowRunCheckpointRuntime,
78
+ input: CreateAIDataWorkflowCheckpointInput,
79
+ ): Promise<AIDataWorkflowRunCheckpoint> {
80
+ return commitAIDataWorkflowCheckpoint(runtime, {
81
+ expectedVersion: null,
82
+ checkpoint: {
83
+ schemaVersion: 'depa.flow-run-checkpoint/v1',
84
+ instanceId: input.instanceId,
85
+ runId: input.graph.runId,
86
+ definition: input.definition,
87
+ version: 0,
88
+ input: input.input,
89
+ config: input.config,
90
+ state: input.state,
91
+ output: input.output,
92
+ controllerSidecars: input.controllerSidecars,
93
+ nodeSidecars: input.nodeSidecars,
94
+ profile: { kind: 'AIDataWorkflow', runGraph: input.graph, ai: input.ai },
95
+ },
96
+ });
97
+ }
98
+
99
+ export async function commitAIDataWorkflowCheckpoint(
100
+ runtime: FlowRunCheckpointRuntime,
101
+ input: FlowRunCheckpointCommit<AIDataWorkflowRunProfileState<AIDataWorkflowRunGraph>>,
102
+ ): Promise<AIDataWorkflowRunCheckpoint> {
103
+ const checkpoint = await commitFlowRunCheckpoint(
104
+ runtime,
105
+ input as unknown as FlowRunCheckpointCommit<FlowRunProfileState>,
106
+ );
107
+ if (checkpoint.profile.kind !== 'AIDataWorkflow') {
108
+ throw new Error(`Flow run ${checkpoint.runId} is not an AI Data Workflow checkpoint.`);
109
+ }
110
+ return checkpoint as unknown as AIDataWorkflowRunCheckpoint;
111
+ }
112
+
113
+ export async function loadAIDataWorkflowCheckpoint(
114
+ runtime: FlowRunCheckpointRuntime,
115
+ input: FlowRunCheckpointKey,
116
+ ): Promise<AIDataWorkflowRunCheckpoint | undefined> {
117
+ const checkpoint = await loadFlowRunCheckpoint(runtime, input);
118
+ if (checkpoint === undefined) return undefined;
119
+ if (checkpoint.profile.kind !== 'AIDataWorkflow') {
120
+ throw new Error(`Flow run ${input.runId} is not an AI Data Workflow checkpoint.`);
121
+ }
122
+ return checkpoint as unknown as AIDataWorkflowRunCheckpoint;
123
+ }
44
124
 
45
125
  export class AIDataWorkflowRunGraphError extends Error {
46
126
  constructor(message: string) {
@@ -51,14 +131,14 @@ export class AIDataWorkflowRunGraphError extends Error {
51
131
 
52
132
  export interface CreateAIDataWorkflowRuntimeOptions<
53
133
  Effects extends AIWorkflowEffectProvider,
54
- Store extends AIWorkflowStateStore,
134
+ Store extends AIWorkflowStateProjection,
55
135
  > {
56
136
  readonly ai: AIWorkflowRuntimeContext<Effects, Store>;
57
137
  }
58
138
 
59
139
  export function createAIDataWorkflowRuntime<
60
140
  Effects extends AIWorkflowEffectProvider,
61
- Store extends AIWorkflowStateStore,
141
+ Store extends AIWorkflowStateProjection,
62
142
  >(
63
143
  options: CreateAIDataWorkflowRuntimeOptions<Effects, Store>,
64
144
  ): AIDataWorkflowRuntime<Effects, Store> {
@@ -67,6 +147,42 @@ export function createAIDataWorkflowRuntime<
67
147
  };
68
148
  }
69
149
 
150
+ export interface CreateAIDataWorkflowAgentNodeRuntimeBinderOptions {
151
+ readonly flowInstanceId: string;
152
+ readonly runId: string;
153
+ readonly checkpointRuntime: FlowRunCheckpointRuntime;
154
+ readonly effects: AIAgentHostEffectPort;
155
+ readonly workflowRef: `resource://${string}`;
156
+ readonly taskProofs: Readonly<Record<string, FrozenAIAgentTaskBinding>>;
157
+ readonly generationForNode: (nodeId: string) => number;
158
+ readonly metadata?: FlowClosedObject;
159
+ }
160
+
161
+ /** Composition seam for ordinary TransformNode and SinkNode Processors. */
162
+ export function createAIDataWorkflowAgentNodeRuntimeBinder(
163
+ options: CreateAIDataWorkflowAgentNodeRuntimeBinderOptions,
164
+ ): (runtime: AIDataWorkflowRuntime<any, AIWorkflowStateProjection>, identity: { readonly nodeId: string }) => AIDataWorkflowRuntime<AIWorkflowAuthoredEffects, AIWorkflowStateProjection> {
165
+ return (runtime, identity) => {
166
+ const generation = options.generationForNode(identity.nodeId);
167
+ const proof = options.taskProofs[identity.nodeId];
168
+ if (!proof) throw new Error(`AI_AGENT_TASK_PROOF_REQUIRED: no frozen proof for node ${identity.nodeId}.`);
169
+ const taskBinding = assertFrozenAIAgentTaskBinding(proof).task;
170
+ const effects = bindAIAgentProcessors({
171
+ checkpointRuntime: options.checkpointRuntime,
172
+ checkpointKey: { instanceId: options.flowInstanceId, runId: options.runId },
173
+ nodeId: identity.nodeId,
174
+ invocationKey: `${identity.nodeId}#${generation}`,
175
+ generation,
176
+ workflowKind: 'AIDataWorkflow',
177
+ workflowRef: options.workflowRef,
178
+ taskBinding,
179
+ effects: options.effects,
180
+ ...(options.metadata === undefined ? {} : { metadata: options.metadata }),
181
+ });
182
+ return Object.freeze({ ai: Object.freeze({ ...runtime.ai, effects }) });
183
+ };
184
+ }
185
+
70
186
  export function defineEagerBackedAIDataWorkflow(
71
187
  definition: AIDataWorkflowDefinitionBinding['definition'],
72
188
  ): AIDataWorkflowDefinitionBinding {
@@ -296,6 +412,73 @@ function assertKnownDependencies(
296
412
  `AI Data Workflow node ${nodeId} depends on unknown node ${dependency}`,
297
413
  );
298
414
  }
415
+ if (nodes[dependency]?.status === 'Removed') {
416
+ throw new AIDataWorkflowRunGraphError(
417
+ `AI Data Workflow node ${nodeId} cannot depend on removed node ${dependency}`,
418
+ );
419
+ }
420
+ }
421
+ }
422
+
423
+ function assertRunGraphInputReferences(
424
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
425
+ ): void {
426
+ for (const node of Object.values(nodes)) {
427
+ for (const [inputName, input] of Object.entries(node.inputs)) {
428
+ if (inputName.length === 0 || inputName !== inputName.trim()) {
429
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow node ${node.id} has an invalid input identity`);
430
+ }
431
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) continue;
432
+ if (!('ref' in input) && !('nodeId' in input) && !('port' in input)) continue;
433
+ const reference = input as { readonly ref?: unknown; readonly nodeId?: unknown; readonly port?: unknown };
434
+ if (typeof reference.nodeId !== 'string' || typeof reference.port !== 'string'
435
+ || reference.ref !== `flow-port://#${reference.nodeId}/${reference.port}`) {
436
+ throw new AIDataWorkflowRunGraphError(
437
+ `AI Data Workflow node ${node.id} input ${inputName} has an invalid flow-port reference`,
438
+ );
439
+ }
440
+ const source = nodes[reference.nodeId];
441
+ if (!source || source.status === 'Removed' || !source.outputs.includes(reference.port)) {
442
+ throw new AIDataWorkflowRunGraphError(
443
+ `AI Data Workflow node ${node.id} input ${inputName} references an undeclared source output`,
444
+ );
445
+ }
446
+ if (!node.dependsOn.includes(reference.nodeId)) {
447
+ throw new AIDataWorkflowRunGraphError(
448
+ `AI Data Workflow node ${node.id} input ${inputName} is not projected by dependsOn`,
449
+ );
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ function assertReusePolicyAuthority(
456
+ reusePolicy: AIWorkflowEffectiveReusePolicy,
457
+ nodeType: AIWorkflowEffectiveReusePolicy['nodeType'],
458
+ config: Readonly<Record<string, unknown>>,
459
+ platformDefaultPolicy: AIWorkflowReusePolicy | undefined,
460
+ nodeId: string,
461
+ ): void {
462
+ if (reusePolicy.nodeType !== nodeType) {
463
+ throw new AIDataWorkflowRunGraphError(
464
+ `AI Data Workflow node ${nodeId} reuse policy nodeType must equal ${nodeType}`,
465
+ );
466
+ }
467
+ const explicitValue = config.reuse_policy ?? config.reusePolicy;
468
+ const explicitPolicy = explicitValue === 'semantic-hash' || explicitValue === 'never'
469
+ ? explicitValue
470
+ : undefined;
471
+ const expected = explicitPolicy
472
+ ? { policy: explicitPolicy, source: 'node', nodeType }
473
+ : nodeType === 'manual'
474
+ ? { policy: 'never', source: 'node-type-default', nodeType }
475
+ : platformDefaultPolicy
476
+ ? { policy: platformDefaultPolicy, source: 'platform-default', nodeType }
477
+ : { policy: 'semantic-hash', source: 'node-type-default', nodeType };
478
+ if (reusePolicy.policy !== expected.policy || reusePolicy.source !== expected.source) {
479
+ throw new AIDataWorkflowRunGraphError(
480
+ `AI Data Workflow node ${nodeId} reuse policy must match its declared config and platform authority`,
481
+ );
299
482
  }
300
483
  }
301
484
 
@@ -319,6 +502,7 @@ export function createAIDataWorkflowRunGraph(
319
502
  explicitPolicy: configReusePolicy(config),
320
503
  platformDefaultPolicy,
321
504
  });
505
+ assertReusePolicyAuthority(reusePolicy, nodeType, config, platformDefaultPolicy, planNode.id);
322
506
  const runNode: AIDataWorkflowRunNode = {
323
507
  id: planNode.id,
324
508
  tag: planNode.tag,
@@ -341,15 +525,18 @@ export function createAIDataWorkflowRunGraph(
341
525
  assertKnownDependencies(nodes, node.id, node.dependsOn);
342
526
  }
343
527
 
344
- return {
528
+ const graph = {
345
529
  runId,
346
530
  binding,
531
+ ...(platformDefaultPolicy === undefined ? {} : { platformDefaultPolicy }),
347
532
  currentGeneration: 0,
348
533
  nodes: recomputeDependents(nodes, declarationOrder),
349
534
  declarationOrder,
350
535
  patchHistory: [],
351
536
  invalidations: [],
352
537
  };
538
+ assertRunGraphInputReferences(graph.nodes);
539
+ return graph;
353
540
  }
354
541
 
355
542
  function makePatchNode(
@@ -360,6 +547,12 @@ function makePatchNode(
360
547
  const tag = spec.tag ?? GENERATED_NODE_TAG;
361
548
  const config = cloneRecord(spec.config);
362
549
  const nodeType = nodeTypeForPatchNode({ ...spec, tag, config });
550
+ const reusePolicy = spec.reusePolicy ?? resolveAIWorkflowReusePolicy({
551
+ nodeType,
552
+ explicitPolicy: configReusePolicy(config),
553
+ platformDefaultPolicy,
554
+ });
555
+ assertReusePolicyAuthority(reusePolicy, nodeType, config, platformDefaultPolicy, spec.id);
363
556
  return {
364
557
  id: spec.id,
365
558
  tag,
@@ -372,11 +565,7 @@ function makePatchNode(
372
565
  config,
373
566
  dependsOn: uniqueStrings(spec.dependsOn ?? []),
374
567
  dependents: [],
375
- reusePolicy: spec.reusePolicy ?? resolveAIWorkflowReusePolicy({
376
- nodeType,
377
- explicitPolicy: configReusePolicy(config),
378
- platformDefaultPolicy,
379
- }),
568
+ reusePolicy,
380
569
  };
381
570
  }
382
571
 
@@ -412,13 +601,18 @@ function applyInvalidations(
412
601
  let nextNodes: Record<string, AIDataWorkflowRunNode> = Object.fromEntries(
413
602
  Object.entries(nodes).map(([nodeId, node]) => [nodeId, cloneNode(node)]),
414
603
  );
415
- const invalidations = existingInvalidations.map(cloneInvalidation);
604
+ let invalidations = existingInvalidations.map(cloneInvalidation);
416
605
  const emitted = new Set<string>();
417
606
 
418
607
  for (const source of sources) {
419
608
  for (const nodeId of collectTransitive(nextNodes, source.nodeId, source.includeSource)) {
420
609
  const node = nextNodes[nodeId];
421
610
  if (!node || node.status === 'Removed') continue;
611
+ if (generation < node.generation) {
612
+ throw new AIDataWorkflowRunGraphError(
613
+ `AI Data Workflow invalidation generation ${generation} precedes node ${nodeId}@${node.generation}`,
614
+ );
615
+ }
422
616
  const key = `${source.nodeId}:${nodeId}:${generation}`;
423
617
  if (emitted.has(key)) continue;
424
618
  emitted.add(key);
@@ -429,14 +623,20 @@ function applyInvalidations(
429
623
  sourceNodeId: source.nodeId,
430
624
  ...(atMs === undefined ? {} : { atMs }),
431
625
  };
626
+ invalidations = invalidations.filter((fact) => fact.nodeId !== nodeId);
432
627
  invalidations.push(invalidation);
628
+ const {
629
+ result: _staleResult,
630
+ semanticFingerprint: _staleFingerprint,
631
+ ...currentFacts
632
+ } = node;
433
633
  nextNodes = {
434
634
  ...nextNodes,
435
635
  [nodeId]: {
436
- ...node,
636
+ ...currentFacts,
437
637
  status: 'Invalidated',
438
638
  generation,
439
- invalidatedBy: [...(node.invalidatedBy ?? []), invalidation],
639
+ invalidatedBy: [invalidation],
440
640
  },
441
641
  };
442
642
  }
@@ -454,6 +654,15 @@ export function invalidateAIDataWorkflowNode(
454
654
  requireNode(graph.nodes, nodeId);
455
655
  const generation = options.generation ?? graph.currentGeneration;
456
656
  const sourceNodeId = options.sourceNodeId ?? nodeId;
657
+ requireNode(graph.nodes, sourceNodeId);
658
+ if (!Number.isSafeInteger(generation) || generation < 0) {
659
+ throw new AIDataWorkflowRunGraphError('AI Data Workflow invalidation generation must be a non-negative safe integer');
660
+ }
661
+ if (generation < graph.currentGeneration) {
662
+ throw new AIDataWorkflowRunGraphError(
663
+ `AI Data Workflow invalidation generation ${generation} precedes current generation ${graph.currentGeneration}`,
664
+ );
665
+ }
457
666
  const cloned = cloneGraph(graph);
458
667
  const { nodes, invalidations } = applyInvalidations(
459
668
  cloned.nodes,
@@ -462,6 +671,7 @@ export function invalidateAIDataWorkflowNode(
462
671
  generation,
463
672
  options.atMs,
464
673
  );
674
+ if (invalidations.length === cloned.invalidations.length) return cloned;
465
675
  return {
466
676
  ...cloned,
467
677
  currentGeneration: Math.max(cloned.currentGeneration, generation),
@@ -477,8 +687,26 @@ export function applyAIDataWorkflowGraphPatch(
477
687
  if (graph.patchHistory.some((record) => record.patchId === patch.patchId)) {
478
688
  throw new AIDataWorkflowRunGraphError(`AI Data Workflow patch ${patch.patchId} already applied`);
479
689
  }
690
+ const patchTargets = new Set<string>();
691
+ for (const operation of patch.operations) {
692
+ const target = operation.op === 'add-node' ? operation.node.id : operation.nodeId;
693
+ if (patchTargets.has(target)) {
694
+ throw new AIDataWorkflowRunGraphError(
695
+ `AI Data Workflow patch ${patch.patchId} targets node ${target} more than once`,
696
+ );
697
+ }
698
+ patchTargets.add(target);
699
+ }
480
700
 
481
- const generation = graph.currentGeneration + 1;
701
+ const generation = patch.generation ?? graph.currentGeneration + 1;
702
+ if (!Number.isSafeInteger(generation) || generation < 1) {
703
+ throw new AIDataWorkflowRunGraphError('AI Data Workflow patch generation must be a positive safe integer');
704
+ }
705
+ if (generation < graph.currentGeneration) {
706
+ throw new AIDataWorkflowRunGraphError(
707
+ `AI Data Workflow patch generation ${generation} precedes current generation ${graph.currentGeneration}`,
708
+ );
709
+ }
482
710
  const atMs = patch.atMs ?? Date.now();
483
711
  let declarationOrder = [...graph.declarationOrder];
484
712
  let nodes = Object.fromEntries(
@@ -499,6 +727,7 @@ export function applyAIDataWorkflowGraphPatch(
499
727
  declarationIndex: operation.node.declarationIndex ?? declarationOrder.length,
500
728
  },
501
729
  generation,
730
+ graph.platformDefaultPolicy,
502
731
  );
503
732
  assertKnownDependencies(nodes, node.id, node.dependsOn);
504
733
  nodes = { ...nodes, [node.id]: node };
@@ -508,6 +737,11 @@ export function applyAIDataWorkflowGraphPatch(
508
737
 
509
738
  if (operation.op === 'update-node') {
510
739
  const existing = requireNode(nodes, operation.nodeId);
740
+ if (generation < existing.generation) {
741
+ throw new AIDataWorkflowRunGraphError(
742
+ `AI Data Workflow patch generation ${generation} precedes node ${existing.id}@${existing.generation}`,
743
+ );
744
+ }
511
745
  if (existing.status === 'Removed') {
512
746
  throw new AIDataWorkflowRunGraphError(
513
747
  `AI Data Workflow node ${operation.nodeId} has been removed`,
@@ -520,13 +754,15 @@ export function applyAIDataWorkflowGraphPatch(
520
754
  : existing.dependsOn;
521
755
  assertKnownDependencies(nodes, existing.id, dependsOn);
522
756
  const reusePolicy = operation.changes.reusePolicy ?? (
523
- operation.changes.config
757
+ operation.changes.config || operation.changes.nodeType
524
758
  ? resolveAIWorkflowReusePolicy({
525
759
  nodeType,
526
760
  explicitPolicy: configReusePolicy(config),
761
+ platformDefaultPolicy: graph.platformDefaultPolicy,
527
762
  })
528
763
  : existing.reusePolicy
529
764
  );
765
+ assertReusePolicyAuthority(reusePolicy, nodeType, config, graph.platformDefaultPolicy, existing.id);
530
766
  nodes = {
531
767
  ...nodes,
532
768
  [existing.id]: {
@@ -551,10 +787,24 @@ export function applyAIDataWorkflowGraphPatch(
551
787
  }
552
788
 
553
789
  const existing = requireNode(nodes, operation.nodeId);
790
+ if (existing.status === 'Removed') {
791
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow node ${operation.nodeId} has already been removed`);
792
+ }
793
+ if (generation < existing.generation) {
794
+ throw new AIDataWorkflowRunGraphError(
795
+ `AI Data Workflow patch generation ${generation} precedes node ${existing.id}@${existing.generation}`,
796
+ );
797
+ }
798
+ const {
799
+ result: _staleResult,
800
+ semanticFingerprint: _staleFingerprint,
801
+ invalidatedBy: _activeInvalidation,
802
+ ...currentFacts
803
+ } = existing;
554
804
  nodes = {
555
805
  ...nodes,
556
806
  [existing.id]: {
557
- ...existing,
807
+ ...currentFacts,
558
808
  generation,
559
809
  status: 'Removed',
560
810
  },
@@ -569,13 +819,13 @@ export function applyAIDataWorkflowGraphPatch(
569
819
  nodes = recomputeDependents(nodes, declarationOrder);
570
820
  const invalidated = applyInvalidations(
571
821
  nodes,
572
- graph.invalidations,
822
+ graph.invalidations.filter((fact) => nodes[fact.nodeId]?.status !== 'Removed'),
573
823
  invalidationSources,
574
824
  generation,
575
825
  atMs,
576
826
  );
577
827
 
578
- return {
828
+ const nextGraph = {
579
829
  ...graph,
580
830
  currentGeneration: generation,
581
831
  nodes: invalidated.nodes,
@@ -592,6 +842,11 @@ export function applyAIDataWorkflowGraphPatch(
592
842
  ],
593
843
  invalidations: invalidated.invalidations,
594
844
  };
845
+ for (const node of Object.values(nextGraph.nodes)) {
846
+ if (node.status !== 'Removed') assertKnownDependencies(nextGraph.nodes, node.id, node.dependsOn);
847
+ }
848
+ assertRunGraphInputReferences(nextGraph.nodes);
849
+ return nextGraph;
595
850
  }
596
851
 
597
852
  function stableSemanticValue(value: unknown): string {
@@ -608,10 +863,14 @@ function stableSemanticValue(value: unknown): string {
608
863
 
609
864
  const entries = Object.entries(value as Record<string, unknown>)
610
865
  .filter(([, item]) => typeof item !== 'function' && typeof item !== 'symbol')
611
- .sort(([left], [right]) => left.localeCompare(right));
866
+ .sort(([left], [right]) => compareCodeUnits(left, right));
612
867
  return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSemanticValue(item)}`).join(',')}}`;
613
868
  }
614
869
 
870
+ function compareCodeUnits(left: string, right: string): number {
871
+ return left < right ? -1 : left > right ? 1 : 0;
872
+ }
873
+
615
874
  function fnv1a64(value: string): string {
616
875
  let hash = 0xcbf29ce484222325n;
617
876
  const prime = 0x100000001b3n;
@@ -695,10 +954,29 @@ export function recordAIDataWorkflowNodeResult(
695
954
  result: AIWorkflowNodeResult,
696
955
  ): AIDataWorkflowRunGraph {
697
956
  const node = requireNode(graph.nodes, nodeId);
957
+ if (result.nodeId !== nodeId) {
958
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow result identity ${result.nodeId} does not match node ${nodeId}`);
959
+ }
960
+ if (result.generation !== node.generation) {
961
+ throw new AIDataWorkflowRunGraphError(
962
+ `AI Data Workflow result generation ${result.generation} does not match node ${nodeId}@${node.generation}`,
963
+ );
964
+ }
965
+ if (node.result
966
+ && (node.result.status === 'Succeeded' || node.result.status === 'Failed' || node.result.status === 'Reused')) {
967
+ if (JSON.stringify(node.result) === JSON.stringify(result)) return cloneGraph(graph);
968
+ throw new AIDataWorkflowRunGraphError(
969
+ `AI Data Workflow terminal result ${nodeId}@${node.generation} is immutable`,
970
+ );
971
+ }
972
+ const {
973
+ invalidatedBy: _acceptedInvalidation,
974
+ ...nodeFacts
975
+ } = node;
698
976
  const nodes = {
699
977
  ...graph.nodes,
700
978
  [nodeId]: {
701
- ...node,
979
+ ...nodeFacts,
702
980
  generation: result.generation,
703
981
  status: toRunNodeStatus(result.status),
704
982
  result: cloneNodeResult(result),
@@ -709,6 +987,7 @@ export function recordAIDataWorkflowNodeResult(
709
987
  ...graph,
710
988
  currentGeneration: Math.max(graph.currentGeneration, result.generation),
711
989
  nodes: recomputeDependents(nodes, graph.declarationOrder),
990
+ invalidations: graph.invalidations.filter((fact) => fact.nodeId !== nodeId),
712
991
  };
713
992
  }
714
993