ai-data-workflow-logic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "ai-data-workflow-logic",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "EagerDataFlow-backed AI Data Workflow RunGraph helpers",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./browser": "./src/browser.ts"
11
+ },
12
+ "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
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.com"
24
+ }
25
+ }
package/src/browser.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './index.js';
package/src/index.ts ADDED
@@ -0,0 +1,746 @@
1
+ import type {
2
+ AIDataWorkflowGraphPatch,
3
+ AIDataWorkflowGraphPatchNodeSpec,
4
+ AIDataWorkflowInvalidation,
5
+ AIDataWorkflowNodeConfig,
6
+ AIDataWorkflowNodeInputs,
7
+ AIDataWorkflowNodeTag,
8
+ AIDataWorkflowReuseCandidate,
9
+ AIDataWorkflowRunGraph,
10
+ AIDataWorkflowRunNode,
11
+ AIDataWorkflowRunNodeStatus,
12
+ AIDataWorkflowSemanticInput,
13
+ } from 'ai-data-workflow-contract';
14
+ import type {
15
+ AIDataWorkflowDefinitionBinding,
16
+ AIDataWorkflowRuntime,
17
+ AIWorkflowDefinitionRef,
18
+ AIWorkflowEffectProvider,
19
+ AIWorkflowEffectiveReusePolicy,
20
+ AIWorkflowNodeResult,
21
+ AIWorkflowNodeStatus,
22
+ AIWorkflowNodeType,
23
+ AIWorkflowReusePolicy,
24
+ AIWorkflowRunState,
25
+ AIWorkflowRunStatus,
26
+ AIWorkflowRuntimeContext,
27
+ AIWorkflowSemanticFingerprint,
28
+ AIWorkflowStateStore,
29
+ } from 'ai-workflow-contract';
30
+ import type {
31
+ EagerDataFlowAuthoringPlan,
32
+ EagerDataFlowNodePlan,
33
+ EagerDataFlowValue,
34
+ } from 'eager-data-flow-contract';
35
+ import {
36
+ createAIWorkflowRuntimeContext,
37
+ defineAIDataWorkflow,
38
+ resolveAIWorkflowReusePolicy,
39
+ } from 'ai-workflow-logic';
40
+
41
+ export class AIDataWorkflowRunGraphError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = 'AIDataWorkflowRunGraphError';
45
+ }
46
+ }
47
+
48
+ export interface CreateAIDataWorkflowRuntimeOptions<
49
+ Effects extends AIWorkflowEffectProvider,
50
+ Store extends AIWorkflowStateStore,
51
+ > {
52
+ readonly ai: AIWorkflowRuntimeContext<Effects, Store>;
53
+ }
54
+
55
+ export function createAIDataWorkflowRuntime<
56
+ Effects extends AIWorkflowEffectProvider,
57
+ Store extends AIWorkflowStateStore,
58
+ >(
59
+ options: CreateAIDataWorkflowRuntimeOptions<Effects, Store>,
60
+ ): AIDataWorkflowRuntime<Effects, Store> {
61
+ return {
62
+ ai: createAIWorkflowRuntimeContext(options.ai),
63
+ };
64
+ }
65
+
66
+ export function defineEagerBackedAIDataWorkflow(
67
+ definition: AIDataWorkflowDefinitionBinding['definition'],
68
+ ): AIDataWorkflowDefinitionBinding {
69
+ return defineAIDataWorkflow(definition);
70
+ }
71
+
72
+ export interface CreateAIDataWorkflowRunGraphOptions {
73
+ readonly binding: AIDataWorkflowDefinitionBinding;
74
+ readonly runId: string;
75
+ readonly platformDefaultPolicy?: AIWorkflowReusePolicy;
76
+ }
77
+
78
+ export interface InvalidateAIDataWorkflowNodeOptions {
79
+ readonly generation?: number;
80
+ readonly sourceNodeId?: string;
81
+ readonly includeSource?: boolean;
82
+ readonly atMs?: number;
83
+ }
84
+
85
+ export interface FindReusableAIDataWorkflowNodeResultInput {
86
+ readonly runId: string;
87
+ readonly nodeId: string;
88
+ readonly fingerprint: AIWorkflowSemanticFingerprint;
89
+ readonly policy: AIWorkflowEffectiveReusePolicy;
90
+ readonly candidates: readonly AIDataWorkflowReuseCandidate[];
91
+ }
92
+
93
+ type NodeOperationSource = {
94
+ readonly nodeId: string;
95
+ readonly includeSource: boolean;
96
+ readonly reason: string;
97
+ };
98
+
99
+ const GENERATED_NODE_TAG = 'TransformNode' satisfies AIDataWorkflowNodeTag;
100
+
101
+ function uniqueStrings(values: Iterable<string>): string[] {
102
+ return [...new Set([...values].filter((value) => value.length > 0))];
103
+ }
104
+
105
+ function cloneRecord<T>(record: Readonly<Record<string, T>> | undefined): Record<string, T> {
106
+ return record ? { ...record } : {};
107
+ }
108
+
109
+ function cloneInvalidation(invalidation: AIDataWorkflowInvalidation): AIDataWorkflowInvalidation {
110
+ return { ...invalidation };
111
+ }
112
+
113
+ function cloneNode(node: AIDataWorkflowRunNode): AIDataWorkflowRunNode {
114
+ return {
115
+ ...node,
116
+ inputs: cloneRecord(node.inputs),
117
+ outputs: [...node.outputs],
118
+ config: cloneRecord(node.config),
119
+ dependsOn: [...node.dependsOn],
120
+ dependents: [...node.dependents],
121
+ reusePolicy: { ...node.reusePolicy },
122
+ ...(node.semanticFingerprint ? { semanticFingerprint: cloneFingerprint(node.semanticFingerprint) } : {}),
123
+ ...(node.result ? { result: cloneNodeResult(node.result) } : {}),
124
+ ...(node.invalidatedBy ? { invalidatedBy: node.invalidatedBy.map(cloneInvalidation) } : {}),
125
+ };
126
+ }
127
+
128
+ function cloneNodeResult(result: AIWorkflowNodeResult): AIWorkflowNodeResult {
129
+ return {
130
+ ...result,
131
+ ...(result.semanticFingerprint ? { semanticFingerprint: cloneFingerprint(result.semanticFingerprint) } : {}),
132
+ ...(result.reusedFrom ? { reusedFrom: { ...result.reusedFrom } } : {}),
133
+ };
134
+ }
135
+
136
+ function cloneFingerprint(fingerprint: AIWorkflowSemanticFingerprint): AIWorkflowSemanticFingerprint {
137
+ return {
138
+ ...fingerprint,
139
+ ...(fingerprint.parts ? { parts: fingerprint.parts.map((part) => ({ ...part })) } : {}),
140
+ };
141
+ }
142
+
143
+ function cloneGraph(graph: AIDataWorkflowRunGraph): AIDataWorkflowRunGraph {
144
+ return {
145
+ ...graph,
146
+ nodes: Object.fromEntries(
147
+ Object.entries(graph.nodes).map(([nodeId, node]) => [nodeId, cloneNode(node)]),
148
+ ),
149
+ declarationOrder: [...graph.declarationOrder],
150
+ patchHistory: graph.patchHistory.map((record) => ({
151
+ ...record,
152
+ operations: record.operations.map((operation) => ({ ...operation })),
153
+ })),
154
+ invalidations: graph.invalidations.map(cloneInvalidation),
155
+ };
156
+ }
157
+
158
+ function planDeclarationOrder(plan: EagerDataFlowAuthoringPlan): string[] {
159
+ if (plan.declarationOrder.length > 0) return [...plan.declarationOrder];
160
+ return [...plan.nodes].sort(
161
+ (left, right) => left.declarationIndex - right.declarationIndex,
162
+ ).map((node) => node.id);
163
+ }
164
+
165
+ function nodeInputs(node: EagerDataFlowNodePlan): AIDataWorkflowNodeInputs {
166
+ if ('inputs' in node) return cloneRecord(node.inputs);
167
+ return {};
168
+ }
169
+
170
+ function nodeOutputs(node: EagerDataFlowNodePlan): readonly string[] {
171
+ if ('outputs' in node) return [...node.outputs];
172
+ return [];
173
+ }
174
+
175
+ function nodeConfig(node: EagerDataFlowNodePlan): AIDataWorkflowNodeConfig {
176
+ if ('config' in node) return cloneRecord(node.config);
177
+ return {};
178
+ }
179
+
180
+ function configReusePolicy(config: AIDataWorkflowNodeConfig): AIWorkflowReusePolicy | undefined {
181
+ const raw = config.reuse_policy ?? config.reusePolicy;
182
+ return raw === 'semantic-hash' || raw === 'never' ? raw : undefined;
183
+ }
184
+
185
+ function nodeTypeForEagerNode(node: EagerDataFlowNodePlan): AIWorkflowNodeType {
186
+ if ('type' in node && node.type === 'manual') return 'manual';
187
+ if (node.tag === 'SinkNode') return 'effect';
188
+ if (node.tag === 'SubFlowNode') return 'subflow';
189
+ return 'data';
190
+ }
191
+
192
+ function nodeTypeForPatchNode(node: AIDataWorkflowGraphPatchNodeSpec): AIWorkflowNodeType {
193
+ if (node.nodeType) return node.nodeType;
194
+ if (node.config?.node_type === 'manual' || node.config?.nodeType === 'manual') return 'manual';
195
+ if (node.tag === 'SinkNode') return 'effect';
196
+ if (node.tag === 'SubFlowNode') return 'subflow';
197
+ return 'data';
198
+ }
199
+
200
+ function dependenciesForPlan(plan: EagerDataFlowAuthoringPlan): Map<string, string[]> {
201
+ const dependencies = new Map<string, Set<string>>();
202
+ for (const node of plan.nodes) dependencies.set(node.id, new Set());
203
+
204
+ for (const edge of [...plan.dataEdges, ...plan.waitForEdges]) {
205
+ dependencies.get(edge.toNodeId)?.add(edge.fromNodeId);
206
+ }
207
+
208
+ for (const node of plan.nodes) {
209
+ for (const wait of node.waitFor) {
210
+ dependencies.get(node.id)?.add(wait.nodeId);
211
+ }
212
+ if ('inputs' in node) {
213
+ for (const ref of Object.values(node.inputs)) {
214
+ dependencies.get(node.id)?.add(ref.nodeId);
215
+ }
216
+ }
217
+ }
218
+
219
+ return new Map(
220
+ [...dependencies.entries()].map(([nodeId, deps]) => [nodeId, uniqueStrings(deps)]),
221
+ );
222
+ }
223
+
224
+ function recomputeDependents(
225
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
226
+ declarationOrder: readonly string[],
227
+ ): Record<string, AIDataWorkflowRunNode> {
228
+ const order = new Map(declarationOrder.map((nodeId, index) => [nodeId, index]));
229
+ const dependents = new Map(Object.keys(nodes).map((nodeId) => [nodeId, new Set<string>()]));
230
+
231
+ for (const node of Object.values(nodes)) {
232
+ for (const dependency of node.dependsOn) {
233
+ dependents.get(dependency)?.add(node.id);
234
+ }
235
+ }
236
+
237
+ return Object.fromEntries(
238
+ Object.entries(nodes).map(([nodeId, node]) => {
239
+ const sortedDependents = [...(dependents.get(nodeId) ?? [])].sort(
240
+ (left, right) => (order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(right) ?? Number.MAX_SAFE_INTEGER),
241
+ );
242
+ return [nodeId, { ...node, dependents: sortedDependents }];
243
+ }),
244
+ );
245
+ }
246
+
247
+ function requireNode(
248
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
249
+ nodeId: string,
250
+ ): AIDataWorkflowRunNode {
251
+ const node = nodes[nodeId];
252
+ if (!node) throw new AIDataWorkflowRunGraphError(`AI Data Workflow node ${nodeId} does not exist`);
253
+ return node;
254
+ }
255
+
256
+ function assertKnownDependencies(
257
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
258
+ nodeId: string,
259
+ dependsOn: readonly string[],
260
+ ): void {
261
+ for (const dependency of dependsOn) {
262
+ if (dependency === nodeId) {
263
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow node ${nodeId} cannot depend on itself`);
264
+ }
265
+ if (!nodes[dependency]) {
266
+ throw new AIDataWorkflowRunGraphError(
267
+ `AI Data Workflow node ${nodeId} depends on unknown node ${dependency}`,
268
+ );
269
+ }
270
+ }
271
+ }
272
+
273
+ export function createAIDataWorkflowRunGraph(
274
+ options: CreateAIDataWorkflowRunGraphOptions,
275
+ ): AIDataWorkflowRunGraph {
276
+ const { binding, runId, platformDefaultPolicy } = options;
277
+ const plan = binding.definition;
278
+ const dependencies = dependenciesForPlan(plan);
279
+ const declarationOrder = planDeclarationOrder(plan);
280
+ const nodes = Object.fromEntries(
281
+ declarationOrder.map((nodeId) => {
282
+ const planNode = plan.nodeById[nodeId] ?? plan.nodes.find((node) => node.id === nodeId);
283
+ if (!planNode) {
284
+ throw new AIDataWorkflowRunGraphError(`EagerDataFlow declarationOrder references missing node ${nodeId}`);
285
+ }
286
+ const config = nodeConfig(planNode);
287
+ const nodeType = nodeTypeForEagerNode(planNode);
288
+ const reusePolicy = resolveAIWorkflowReusePolicy({
289
+ nodeType,
290
+ explicitPolicy: configReusePolicy(config),
291
+ platformDefaultPolicy,
292
+ });
293
+ const runNode: AIDataWorkflowRunNode = {
294
+ id: planNode.id,
295
+ tag: planNode.tag,
296
+ nodeType,
297
+ generation: 0,
298
+ status: 'Pending',
299
+ declarationIndex: planNode.declarationIndex,
300
+ inputs: nodeInputs(planNode),
301
+ outputs: nodeOutputs(planNode),
302
+ config,
303
+ dependsOn: dependencies.get(planNode.id) ?? [],
304
+ dependents: [],
305
+ reusePolicy,
306
+ };
307
+ return [planNode.id, runNode];
308
+ }),
309
+ );
310
+
311
+ for (const node of Object.values(nodes)) {
312
+ assertKnownDependencies(nodes, node.id, node.dependsOn);
313
+ }
314
+
315
+ return {
316
+ runId,
317
+ binding,
318
+ currentGeneration: 0,
319
+ nodes: recomputeDependents(nodes, declarationOrder),
320
+ declarationOrder,
321
+ patchHistory: [],
322
+ invalidations: [],
323
+ };
324
+ }
325
+
326
+ function makePatchNode(
327
+ spec: AIDataWorkflowGraphPatchNodeSpec,
328
+ generation: number,
329
+ platformDefaultPolicy?: AIWorkflowReusePolicy,
330
+ ): AIDataWorkflowRunNode {
331
+ const tag = spec.tag ?? GENERATED_NODE_TAG;
332
+ const config = cloneRecord(spec.config);
333
+ const nodeType = nodeTypeForPatchNode({ ...spec, tag, config });
334
+ return {
335
+ id: spec.id,
336
+ tag,
337
+ nodeType,
338
+ generation,
339
+ status: 'Pending',
340
+ declarationIndex: spec.declarationIndex ?? 0,
341
+ inputs: cloneRecord(spec.inputs),
342
+ outputs: [...(spec.outputs ?? [])],
343
+ config,
344
+ dependsOn: uniqueStrings(spec.dependsOn ?? []),
345
+ dependents: [],
346
+ reusePolicy: spec.reusePolicy ?? resolveAIWorkflowReusePolicy({
347
+ nodeType,
348
+ explicitPolicy: configReusePolicy(config),
349
+ platformDefaultPolicy,
350
+ }),
351
+ };
352
+ }
353
+
354
+ function collectTransitive(
355
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
356
+ sourceNodeId: string,
357
+ includeSource: boolean,
358
+ ): string[] {
359
+ const visited = new Set<string>();
360
+ const queue = includeSource ? [sourceNodeId] : [...(nodes[sourceNodeId]?.dependents ?? [])];
361
+
362
+ for (const nodeId of queue) {
363
+ if (visited.has(nodeId)) continue;
364
+ visited.add(nodeId);
365
+ const node = nodes[nodeId];
366
+ if (!node || node.status === 'Removed') continue;
367
+ queue.push(...node.dependents);
368
+ }
369
+
370
+ return [...visited].filter((nodeId) => nodes[nodeId]?.status !== 'Removed');
371
+ }
372
+
373
+ function applyInvalidations(
374
+ nodes: Readonly<Record<string, AIDataWorkflowRunNode>>,
375
+ existingInvalidations: readonly AIDataWorkflowInvalidation[],
376
+ sources: readonly NodeOperationSource[],
377
+ generation: number,
378
+ atMs?: number,
379
+ ): {
380
+ readonly nodes: Record<string, AIDataWorkflowRunNode>;
381
+ readonly invalidations: readonly AIDataWorkflowInvalidation[];
382
+ } {
383
+ let nextNodes: Record<string, AIDataWorkflowRunNode> = Object.fromEntries(
384
+ Object.entries(nodes).map(([nodeId, node]) => [nodeId, cloneNode(node)]),
385
+ );
386
+ const invalidations = existingInvalidations.map(cloneInvalidation);
387
+ const emitted = new Set<string>();
388
+
389
+ for (const source of sources) {
390
+ for (const nodeId of collectTransitive(nextNodes, source.nodeId, source.includeSource)) {
391
+ const node = nextNodes[nodeId];
392
+ if (!node || node.status === 'Removed') continue;
393
+ const key = `${source.nodeId}:${nodeId}:${generation}`;
394
+ if (emitted.has(key)) continue;
395
+ emitted.add(key);
396
+ const invalidation: AIDataWorkflowInvalidation = {
397
+ nodeId,
398
+ generation,
399
+ reason: source.reason,
400
+ sourceNodeId: source.nodeId,
401
+ ...(atMs === undefined ? {} : { atMs }),
402
+ };
403
+ invalidations.push(invalidation);
404
+ nextNodes = {
405
+ ...nextNodes,
406
+ [nodeId]: {
407
+ ...node,
408
+ status: 'Invalidated',
409
+ generation,
410
+ invalidatedBy: [...(node.invalidatedBy ?? []), invalidation],
411
+ },
412
+ };
413
+ }
414
+ }
415
+
416
+ return { nodes: nextNodes, invalidations };
417
+ }
418
+
419
+ export function invalidateAIDataWorkflowNode(
420
+ graph: AIDataWorkflowRunGraph,
421
+ nodeId: string,
422
+ reason: string,
423
+ options: InvalidateAIDataWorkflowNodeOptions = {},
424
+ ): AIDataWorkflowRunGraph {
425
+ requireNode(graph.nodes, nodeId);
426
+ const generation = options.generation ?? graph.currentGeneration;
427
+ const sourceNodeId = options.sourceNodeId ?? nodeId;
428
+ const cloned = cloneGraph(graph);
429
+ const { nodes, invalidations } = applyInvalidations(
430
+ cloned.nodes,
431
+ cloned.invalidations,
432
+ [{ nodeId: sourceNodeId, includeSource: options.includeSource ?? true, reason }],
433
+ generation,
434
+ options.atMs,
435
+ );
436
+ return {
437
+ ...cloned,
438
+ currentGeneration: Math.max(cloned.currentGeneration, generation),
439
+ nodes,
440
+ invalidations,
441
+ };
442
+ }
443
+
444
+ export function applyAIDataWorkflowGraphPatch(
445
+ graph: AIDataWorkflowRunGraph,
446
+ patch: AIDataWorkflowGraphPatch,
447
+ ): AIDataWorkflowRunGraph {
448
+ if (graph.patchHistory.some((record) => record.patchId === patch.patchId)) {
449
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow patch ${patch.patchId} already applied`);
450
+ }
451
+
452
+ const generation = graph.currentGeneration + 1;
453
+ const atMs = patch.atMs ?? Date.now();
454
+ let declarationOrder = [...graph.declarationOrder];
455
+ let nodes = Object.fromEntries(
456
+ Object.entries(graph.nodes).map(([nodeId, node]) => [nodeId, cloneNode(node)]),
457
+ );
458
+ const invalidationSources: NodeOperationSource[] = [];
459
+
460
+ for (const operation of patch.operations) {
461
+ if (operation.op === 'add-node') {
462
+ if (nodes[operation.node.id]) {
463
+ throw new AIDataWorkflowRunGraphError(
464
+ `AI Data Workflow node ${operation.node.id} already exists`,
465
+ );
466
+ }
467
+ const node = makePatchNode(
468
+ {
469
+ ...operation.node,
470
+ declarationIndex: operation.node.declarationIndex ?? declarationOrder.length,
471
+ },
472
+ generation,
473
+ );
474
+ assertKnownDependencies(nodes, node.id, node.dependsOn);
475
+ nodes = { ...nodes, [node.id]: node };
476
+ declarationOrder = [...declarationOrder, node.id];
477
+ continue;
478
+ }
479
+
480
+ if (operation.op === 'update-node') {
481
+ const existing = requireNode(nodes, operation.nodeId);
482
+ if (existing.status === 'Removed') {
483
+ throw new AIDataWorkflowRunGraphError(
484
+ `AI Data Workflow node ${operation.nodeId} has been removed`,
485
+ );
486
+ }
487
+ const config = operation.changes.config ? cloneRecord(operation.changes.config) : existing.config;
488
+ const nodeType = operation.changes.nodeType ?? existing.nodeType;
489
+ const dependsOn = operation.changes.dependsOn
490
+ ? uniqueStrings(operation.changes.dependsOn)
491
+ : existing.dependsOn;
492
+ assertKnownDependencies(nodes, existing.id, dependsOn);
493
+ const reusePolicy = operation.changes.reusePolicy ?? (
494
+ operation.changes.config
495
+ ? resolveAIWorkflowReusePolicy({
496
+ nodeType,
497
+ explicitPolicy: configReusePolicy(config),
498
+ })
499
+ : existing.reusePolicy
500
+ );
501
+ nodes = {
502
+ ...nodes,
503
+ [existing.id]: {
504
+ ...existing,
505
+ ...operation.changes,
506
+ tag: operation.changes.tag ?? existing.tag,
507
+ nodeType,
508
+ generation,
509
+ inputs: operation.changes.inputs ? cloneRecord(operation.changes.inputs) : existing.inputs,
510
+ outputs: operation.changes.outputs ? [...operation.changes.outputs] : existing.outputs,
511
+ config,
512
+ dependsOn,
513
+ reusePolicy,
514
+ },
515
+ };
516
+ invalidationSources.push({
517
+ nodeId: existing.id,
518
+ includeSource: true,
519
+ reason: patch.reason ?? `patch:${patch.patchId}`,
520
+ });
521
+ continue;
522
+ }
523
+
524
+ const existing = requireNode(nodes, operation.nodeId);
525
+ nodes = {
526
+ ...nodes,
527
+ [existing.id]: {
528
+ ...existing,
529
+ generation,
530
+ status: 'Removed',
531
+ },
532
+ };
533
+ invalidationSources.push({
534
+ nodeId: existing.id,
535
+ includeSource: false,
536
+ reason: patch.reason ?? `patch:${patch.patchId}`,
537
+ });
538
+ }
539
+
540
+ nodes = recomputeDependents(nodes, declarationOrder);
541
+ const invalidated = applyInvalidations(
542
+ nodes,
543
+ graph.invalidations,
544
+ invalidationSources,
545
+ generation,
546
+ atMs,
547
+ );
548
+
549
+ return {
550
+ ...graph,
551
+ currentGeneration: generation,
552
+ nodes: invalidated.nodes,
553
+ declarationOrder,
554
+ patchHistory: [
555
+ ...graph.patchHistory,
556
+ {
557
+ patchId: patch.patchId,
558
+ generation,
559
+ ...(patch.reason ? { reason: patch.reason } : {}),
560
+ operations: patch.operations,
561
+ atMs,
562
+ },
563
+ ],
564
+ invalidations: invalidated.invalidations,
565
+ };
566
+ }
567
+
568
+ function stableSemanticValue(value: unknown): string {
569
+ if (value === null) return 'null';
570
+ if (value === undefined) return '{"$undefined":true}';
571
+ if (typeof value === 'number') {
572
+ if (Number.isNaN(value)) return '{"$number":"NaN"}';
573
+ if (value === Infinity) return '{"$number":"Infinity"}';
574
+ if (value === -Infinity) return '{"$number":"-Infinity"}';
575
+ }
576
+ if (typeof value === 'bigint') return `{"$bigint":${JSON.stringify(value.toString())}}`;
577
+ if (typeof value !== 'object') return JSON.stringify(value);
578
+ if (Array.isArray(value)) return `[${value.map(stableSemanticValue).join(',')}]`;
579
+
580
+ const entries = Object.entries(value as Record<string, unknown>)
581
+ .filter(([, item]) => typeof item !== 'function' && typeof item !== 'symbol')
582
+ .sort(([left], [right]) => left.localeCompare(right));
583
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableSemanticValue(item)}`).join(',')}}`;
584
+ }
585
+
586
+ function fnv1a64(value: string): string {
587
+ let hash = 0xcbf29ce484222325n;
588
+ const prime = 0x100000001b3n;
589
+ const mask = 0xffffffffffffffffn;
590
+ for (let index = 0; index < value.length; index += 1) {
591
+ hash ^= BigInt(value.charCodeAt(index));
592
+ hash = (hash * prime) & mask;
593
+ }
594
+ return hash.toString(16).padStart(16, '0');
595
+ }
596
+
597
+ export function createAIDataWorkflowSemanticFingerprint(
598
+ input: AIDataWorkflowSemanticInput,
599
+ ): AIWorkflowSemanticFingerprint {
600
+ const canonical = stableSemanticValue({
601
+ nodeId: input.nodeId,
602
+ nodeTag: input.nodeTag,
603
+ effectiveInput: input.effectiveInput,
604
+ config: input.config ?? {},
605
+ materialRevisions: input.materialRevisions ?? {},
606
+ effectPolicy: input.effectPolicy ?? {},
607
+ });
608
+ return {
609
+ algorithm: 'semantic-hash/v1',
610
+ value: `fnv1a64:${fnv1a64(canonical)}`,
611
+ parts: [{ name: 'canonical', value: canonical }],
612
+ };
613
+ }
614
+
615
+ export function findReusableAIDataWorkflowNodeResult(
616
+ input: FindReusableAIDataWorkflowNodeResultInput,
617
+ ): AIWorkflowNodeResult | undefined {
618
+ if (input.policy.policy === 'never') return undefined;
619
+ return input.candidates.find((candidate) =>
620
+ candidate.runId === input.runId &&
621
+ candidate.nodeId === input.nodeId &&
622
+ candidate.semanticFingerprint.algorithm === input.fingerprint.algorithm &&
623
+ candidate.semanticFingerprint.value === input.fingerprint.value
624
+ )?.result;
625
+ }
626
+
627
+ function toRunNodeStatus(status: AIWorkflowNodeStatus): AIDataWorkflowRunNodeStatus {
628
+ switch (status) {
629
+ case 'Pending':
630
+ case 'Running':
631
+ case 'Succeeded':
632
+ case 'Failed':
633
+ case 'Invalidated':
634
+ case 'Reused':
635
+ return status;
636
+ case 'Waiting':
637
+ return 'Running';
638
+ case 'Cancelled':
639
+ return 'Failed';
640
+ }
641
+ }
642
+
643
+ function toWorkflowNodeStatus(status: AIDataWorkflowRunNodeStatus): AIWorkflowNodeStatus {
644
+ switch (status) {
645
+ case 'Pending':
646
+ case 'Ready':
647
+ return 'Pending';
648
+ case 'Running':
649
+ return 'Running';
650
+ case 'Succeeded':
651
+ return 'Succeeded';
652
+ case 'Failed':
653
+ return 'Failed';
654
+ case 'Invalidated':
655
+ return 'Invalidated';
656
+ case 'Reused':
657
+ return 'Reused';
658
+ case 'Removed':
659
+ return 'Cancelled';
660
+ }
661
+ }
662
+
663
+ export function recordAIDataWorkflowNodeResult(
664
+ graph: AIDataWorkflowRunGraph,
665
+ nodeId: string,
666
+ result: AIWorkflowNodeResult,
667
+ ): AIDataWorkflowRunGraph {
668
+ const node = requireNode(graph.nodes, nodeId);
669
+ const nodes = {
670
+ ...graph.nodes,
671
+ [nodeId]: {
672
+ ...node,
673
+ generation: result.generation,
674
+ status: toRunNodeStatus(result.status),
675
+ result: cloneNodeResult(result),
676
+ ...(result.semanticFingerprint ? { semanticFingerprint: cloneFingerprint(result.semanticFingerprint) } : {}),
677
+ },
678
+ };
679
+ return {
680
+ ...graph,
681
+ currentGeneration: Math.max(graph.currentGeneration, result.generation),
682
+ nodes: recomputeDependents(nodes, graph.declarationOrder),
683
+ };
684
+ }
685
+
686
+ export function listReadyAIDataWorkflowNodes(
687
+ graph: AIDataWorkflowRunGraph,
688
+ ): readonly AIDataWorkflowRunNode[] {
689
+ return graph.declarationOrder
690
+ .map((nodeId) => graph.nodes[nodeId])
691
+ .filter((node): node is AIDataWorkflowRunNode => Boolean(node))
692
+ .filter((node) =>
693
+ (node.status === 'Pending' || node.status === 'Invalidated') &&
694
+ node.dependsOn.every((dependency) => {
695
+ const dependencyNode = graph.nodes[dependency];
696
+ return dependencyNode?.status === 'Succeeded' || dependencyNode?.status === 'Reused';
697
+ })
698
+ );
699
+ }
700
+
701
+ function graphStatus(graph: AIDataWorkflowRunGraph): AIWorkflowRunStatus {
702
+ const nodes = Object.values(graph.nodes).filter((node) => node.status !== 'Removed');
703
+ if (nodes.some((node) => node.status === 'Failed')) return 'Failed';
704
+ if (nodes.some((node) => node.status === 'Running')) return 'Running';
705
+ if (nodes.length > 0 && nodes.every((node) => node.status === 'Succeeded' || node.status === 'Reused')) {
706
+ return 'Succeeded';
707
+ }
708
+ return 'Pending';
709
+ }
710
+
711
+ export function projectAIDataWorkflowRunState(
712
+ graph: AIDataWorkflowRunGraph,
713
+ workflow: AIWorkflowDefinitionRef,
714
+ ): AIWorkflowRunState {
715
+ const nodes = Object.fromEntries(
716
+ Object.entries(graph.nodes)
717
+ .filter(([, node]) => node.status !== 'Removed')
718
+ .map(([nodeId, node]) => {
719
+ const result = node.result ?? {
720
+ nodeId,
721
+ generation: node.generation,
722
+ status: toWorkflowNodeStatus(node.status),
723
+ ...(node.semanticFingerprint ? { semanticFingerprint: node.semanticFingerprint } : {}),
724
+ };
725
+ return [nodeId, result];
726
+ }),
727
+ );
728
+
729
+ return {
730
+ ref: {
731
+ workflow,
732
+ runId: graph.runId,
733
+ generation: graph.currentGeneration,
734
+ },
735
+ status: graphStatus(graph),
736
+ nodes,
737
+ metadata: {
738
+ kind: 'AIDataWorkflow',
739
+ fqn: graph.binding.definition.fqn,
740
+ },
741
+ };
742
+ }
743
+
744
+ export function restoreAIDataWorkflowRunGraph(graph: AIDataWorkflowRunGraph): AIDataWorkflowRunGraph {
745
+ return cloneGraph(graph);
746
+ }