ai-data-workflow-logic 0.1.11 → 0.1.14

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,376 @@
1
+ import type {
2
+ AIDataWorkflowAutonomousSubFlowBinding,
3
+ AIDataWorkflowChildFreezeReceipt,
4
+ AIDataWorkflowChildInvocationIdentity,
5
+ AIDataWorkflowChildInvocationObservation,
6
+ AIDataWorkflowChildTerminalReceipt,
7
+ AIDataWorkflowRunGraph,
8
+ } from 'ai-data-workflow-contract';
9
+ import { AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION } from 'ai-data-workflow-contract';
10
+ import { normalizeFlowClosedObject } from 'ai-workflow-logic';
11
+
12
+ export class AIDataWorkflowChildInvocationError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = 'AIDataWorkflowChildInvocationError';
16
+ }
17
+ }
18
+
19
+ function fnv1a64(value: string): string {
20
+ let hash = 0xcbf29ce484222325n;
21
+ const prime = 0x100000001b3n;
22
+ const mask = 0xffffffffffffffffn;
23
+ for (let index = 0; index < value.length; index += 1) {
24
+ hash ^= BigInt(value.charCodeAt(index));
25
+ hash = (hash * prime) & mask;
26
+ }
27
+ return hash.toString(16).padStart(16, '0');
28
+ }
29
+
30
+ const compareCodeUnits = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
31
+
32
+ function record(value: unknown, keys: readonly string[], location: string): Readonly<Record<string, unknown>> {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
34
+ throw new AIDataWorkflowChildInvocationError(`${location} must be an object.`);
35
+ }
36
+ const input = value as Readonly<Record<string, unknown>>;
37
+ const actual = Object.keys(input).sort(compareCodeUnits);
38
+ const expected = [...keys].sort(compareCodeUnits);
39
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
40
+ throw new AIDataWorkflowChildInvocationError(`${location} fields are invalid.`);
41
+ }
42
+ return input;
43
+ }
44
+
45
+ function nonEmpty(value: unknown, location: string): string {
46
+ if (typeof value !== 'string' || value.length === 0) {
47
+ throw new AIDataWorkflowChildInvocationError(`${location} must be a non-empty string.`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ function prefixed(value: unknown, prefix: string, location: string): string {
53
+ const normalized = nonEmpty(value, location);
54
+ if (!normalized.startsWith(prefix) || normalized.length === prefix.length) {
55
+ throw new AIDataWorkflowChildInvocationError(`${location} must start with ${prefix}.`);
56
+ }
57
+ return normalized;
58
+ }
59
+
60
+ function schema(value: unknown, location: string): void {
61
+ if (value !== AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION) {
62
+ throw new AIDataWorkflowChildInvocationError(`${location} uses an unsupported schema version.`);
63
+ }
64
+ }
65
+
66
+ function normalizeSubflow(value: unknown, location: string): AIDataWorkflowAutonomousSubFlowBinding {
67
+ const input = record(value, [
68
+ 'kind', 'schemaVersion', 'capabilityId', 'catalogDigest', 'definitionRef', 'flowRef', 'contractDigest',
69
+ ], location);
70
+ if (input.kind !== 'autonomous' || input.schemaVersion !== 'depa.ai-data-subflow-binding/v1') {
71
+ throw new AIDataWorkflowChildInvocationError(`${location} must be an autonomous subflow binding.`);
72
+ }
73
+ return Object.freeze({
74
+ kind: 'autonomous',
75
+ schemaVersion: 'depa.ai-data-subflow-binding/v1',
76
+ capabilityId: nonEmpty(input.capabilityId, `${location}.capabilityId`),
77
+ catalogDigest: nonEmpty(input.catalogDigest, `${location}.catalogDigest`),
78
+ definitionRef: prefixed(input.definitionRef, 'resource://', `${location}.definitionRef`) as `resource://${string}`,
79
+ flowRef: prefixed(input.flowRef, 'eager-data-flow://', `${location}.flowRef`) as `eager-data-flow://${string}`,
80
+ contractDigest: nonEmpty(input.contractDigest, `${location}.contractDigest`),
81
+ });
82
+ }
83
+
84
+ function sameSubflow(
85
+ left: AIDataWorkflowAutonomousSubFlowBinding,
86
+ right: AIDataWorkflowAutonomousSubFlowBinding,
87
+ ): boolean {
88
+ return left.kind === right.kind
89
+ && left.schemaVersion === right.schemaVersion
90
+ && left.capabilityId === right.capabilityId
91
+ && left.catalogDigest === right.catalogDigest
92
+ && left.definitionRef === right.definitionRef
93
+ && left.flowRef === right.flowRef
94
+ && left.contractDigest === right.contractDigest;
95
+ }
96
+
97
+ export function createAIDataWorkflowChildInvocationIdentity(input: {
98
+ readonly graph: AIDataWorkflowRunGraph;
99
+ readonly parentInstanceId: string;
100
+ readonly nodeId: string;
101
+ }): AIDataWorkflowChildInvocationIdentity {
102
+ const node = input.graph.nodes[input.nodeId];
103
+ if (!node || node.tag !== 'SubFlowNode' || node.subflow?.kind !== 'autonomous') {
104
+ throw new AIDataWorkflowChildInvocationError(
105
+ `AI Data Workflow node ${input.nodeId} is not an autonomous SubFlowNode.`,
106
+ );
107
+ }
108
+ const canonical = JSON.stringify([
109
+ input.parentInstanceId,
110
+ input.graph.runId,
111
+ node.id,
112
+ node.generation,
113
+ node.subflow.capabilityId,
114
+ node.subflow.catalogDigest,
115
+ node.subflow.definitionRef,
116
+ node.subflow.flowRef,
117
+ node.subflow.contractDigest,
118
+ ]);
119
+ const suffix = fnv1a64(canonical);
120
+ return normalizeAIDataWorkflowChildInvocationIdentity({
121
+ schemaVersion: AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION,
122
+ invocationKey: `ai-data-child:${suffix}`,
123
+ parentInstanceId: input.parentInstanceId,
124
+ parentRunId: input.graph.runId,
125
+ parentNodeId: node.id,
126
+ parentGeneration: node.generation,
127
+ childInstanceId: `${input.parentInstanceId}--child-${suffix}`,
128
+ childRunId: `${input.graph.runId}--child-${suffix}`,
129
+ subflow: node.subflow,
130
+ });
131
+ }
132
+
133
+ /** Pure parent-side reservation. A differing winner is rejected; an exact retry is idempotent. */
134
+ export function reserveAIDataWorkflowChildInvocation(input: {
135
+ readonly graph: AIDataWorkflowRunGraph;
136
+ readonly identity: AIDataWorkflowChildInvocationIdentity;
137
+ }): AIDataWorkflowRunGraph {
138
+ const identity = normalizeAIDataWorkflowChildInvocationIdentity(input.identity);
139
+ const node = input.graph.nodes[identity.parentNodeId];
140
+ if (!node || node.tag !== 'SubFlowNode' || node.subflow?.kind !== 'autonomous') {
141
+ throw new AIDataWorkflowChildInvocationError(
142
+ `AI Data Workflow node ${identity.parentNodeId} is not an autonomous SubFlowNode.`,
143
+ );
144
+ }
145
+ if (input.graph.runId !== identity.parentRunId || node.generation !== identity.parentGeneration
146
+ || !sameSubflow(node.subflow, identity.subflow)) {
147
+ throw new AIDataWorkflowChildInvocationError('Child invocation reservation does not match the current parent generation.');
148
+ }
149
+ if (node.status === 'Succeeded' || node.status === 'Failed' || node.status === 'Reused' || node.status === 'Removed') {
150
+ throw new AIDataWorkflowChildInvocationError(`Cannot reserve a child for terminal node ${node.id}.`);
151
+ }
152
+ if (node.childInvocation) {
153
+ const current = normalizeAIDataWorkflowChildInvocationIdentity(node.childInvocation);
154
+ if (JSON.stringify(current) !== JSON.stringify(identity)) {
155
+ throw new AIDataWorkflowChildInvocationError(`Node ${node.id} already reserved another child invocation.`);
156
+ }
157
+ return input.graph;
158
+ }
159
+ return Object.freeze({
160
+ ...input.graph,
161
+ nodes: Object.freeze({
162
+ ...input.graph.nodes,
163
+ [node.id]: Object.freeze({
164
+ ...node,
165
+ status: 'Running' as const,
166
+ childInvocation: identity,
167
+ }),
168
+ }),
169
+ });
170
+ }
171
+
172
+ export function recordAIDataWorkflowChildFreezeReceipt(input: {
173
+ readonly graph: AIDataWorkflowRunGraph;
174
+ readonly nodeId: string;
175
+ readonly receipt: AIDataWorkflowChildFreezeReceipt;
176
+ }): AIDataWorkflowRunGraph {
177
+ const node = input.graph.nodes[input.nodeId];
178
+ if (!node?.childInvocation) {
179
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} has no reserved child invocation.`);
180
+ }
181
+ const receipt = normalizeAIDataWorkflowChildFreezeReceipt(input.receipt, node.childInvocation);
182
+ if (node.childFreezeReceipt) {
183
+ if (JSON.stringify(node.childFreezeReceipt) === JSON.stringify(receipt)) return input.graph;
184
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} already froze another child definition.`);
185
+ }
186
+ return Object.freeze({
187
+ ...input.graph,
188
+ nodes: Object.freeze({
189
+ ...input.graph.nodes,
190
+ [node.id]: Object.freeze({ ...node, childFreezeReceipt: receipt }),
191
+ }),
192
+ });
193
+ }
194
+
195
+ /** Accepts an immutable child terminal receipt as the exact parent node settlement. */
196
+ export function acceptAIDataWorkflowChildTerminalReceipt(input: {
197
+ readonly graph: AIDataWorkflowRunGraph;
198
+ readonly nodeId: string;
199
+ readonly receipt: AIDataWorkflowChildTerminalReceipt;
200
+ }): AIDataWorkflowRunGraph {
201
+ const node = input.graph.nodes[input.nodeId];
202
+ if (!node?.childInvocation || !node.childFreezeReceipt) {
203
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} is not frozen for child settlement.`);
204
+ }
205
+ const receipt = normalizeAIDataWorkflowChildTerminalReceipt(input.receipt, node.childInvocation);
206
+ if (node.childTerminalReceipt) {
207
+ if (JSON.stringify(node.childTerminalReceipt) === JSON.stringify(receipt)) return input.graph;
208
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} already accepted another child terminal receipt.`);
209
+ }
210
+ if (node.status !== 'Running') {
211
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} must be Running before child settlement.`);
212
+ }
213
+ if (receipt.status === 'Succeeded') {
214
+ const expected = [...node.outputs].sort(compareCodeUnits);
215
+ const actual = Object.keys(receipt.output).sort(compareCodeUnits);
216
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
217
+ throw new AIDataWorkflowChildInvocationError(`Node ${input.nodeId} child output does not match its FlowContract.`);
218
+ }
219
+ }
220
+ const result = receipt.status === 'Succeeded'
221
+ ? { nodeId: node.id, generation: node.generation, status: 'Succeeded' as const, output: receipt.output }
222
+ : { nodeId: node.id, generation: node.generation, status: 'Failed' as const };
223
+ return Object.freeze({
224
+ ...input.graph,
225
+ nodes: Object.freeze({
226
+ ...input.graph.nodes,
227
+ [node.id]: Object.freeze({
228
+ ...node,
229
+ status: receipt.status,
230
+ result: Object.freeze(result),
231
+ childTerminalReceipt: receipt,
232
+ }),
233
+ }),
234
+ invalidations: Object.freeze(input.graph.invalidations.filter((fact) => fact.nodeId !== node.id)),
235
+ });
236
+ }
237
+
238
+ export function normalizeAIDataWorkflowChildInvocationIdentity(
239
+ value: unknown,
240
+ location = 'childInvocation.identity',
241
+ ): AIDataWorkflowChildInvocationIdentity {
242
+ const input = record(value, [
243
+ 'schemaVersion', 'invocationKey', 'parentInstanceId', 'parentRunId', 'parentNodeId', 'parentGeneration',
244
+ 'childInstanceId', 'childRunId', 'subflow',
245
+ ], location);
246
+ schema(input.schemaVersion, `${location}.schemaVersion`);
247
+ if (!Number.isSafeInteger(input.parentGeneration) || Number(input.parentGeneration) < 0) {
248
+ throw new AIDataWorkflowChildInvocationError(`${location}.parentGeneration must be a non-negative safe integer.`);
249
+ }
250
+ return Object.freeze({
251
+ schemaVersion: AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION,
252
+ invocationKey: nonEmpty(input.invocationKey, `${location}.invocationKey`),
253
+ parentInstanceId: nonEmpty(input.parentInstanceId, `${location}.parentInstanceId`),
254
+ parentRunId: nonEmpty(input.parentRunId, `${location}.parentRunId`),
255
+ parentNodeId: nonEmpty(input.parentNodeId, `${location}.parentNodeId`),
256
+ parentGeneration: Number(input.parentGeneration),
257
+ childInstanceId: nonEmpty(input.childInstanceId, `${location}.childInstanceId`),
258
+ childRunId: nonEmpty(input.childRunId, `${location}.childRunId`),
259
+ subflow: normalizeSubflow(input.subflow, `${location}.subflow`),
260
+ });
261
+ }
262
+
263
+ export function normalizeAIDataWorkflowChildFreezeReceipt(
264
+ value: unknown,
265
+ identity: AIDataWorkflowChildInvocationIdentity,
266
+ location = 'childInvocation.freeze',
267
+ ): AIDataWorkflowChildFreezeReceipt {
268
+ const input = record(value, [
269
+ 'schemaVersion', 'invocationKey', 'childInstanceId', 'childRunId', 'definitionRef', 'flowRef',
270
+ 'contractDigest', 'definitionRevision', 'definitionDigest',
271
+ ], location);
272
+ schema(input.schemaVersion, `${location}.schemaVersion`);
273
+ const normalized = Object.freeze({
274
+ schemaVersion: AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION,
275
+ invocationKey: nonEmpty(input.invocationKey, `${location}.invocationKey`),
276
+ childInstanceId: nonEmpty(input.childInstanceId, `${location}.childInstanceId`),
277
+ childRunId: nonEmpty(input.childRunId, `${location}.childRunId`),
278
+ definitionRef: prefixed(input.definitionRef, 'resource://', `${location}.definitionRef`) as `resource://${string}`,
279
+ flowRef: prefixed(input.flowRef, 'eager-data-flow://', `${location}.flowRef`) as `eager-data-flow://${string}`,
280
+ contractDigest: nonEmpty(input.contractDigest, `${location}.contractDigest`),
281
+ definitionRevision: nonEmpty(input.definitionRevision, `${location}.definitionRevision`),
282
+ definitionDigest: nonEmpty(input.definitionDigest, `${location}.definitionDigest`),
283
+ });
284
+ assertChildIdentity(normalized, identity, location);
285
+ return normalized;
286
+ }
287
+
288
+ export function normalizeAIDataWorkflowChildInvocationObservation(
289
+ value: unknown,
290
+ identity: AIDataWorkflowChildInvocationIdentity,
291
+ location = 'childInvocation.observation',
292
+ ): AIDataWorkflowChildInvocationObservation {
293
+ const input = record(value, ['schemaVersion', 'invocationKey', 'childInstanceId', 'childRunId', 'status'], location);
294
+ schema(input.schemaVersion, `${location}.schemaVersion`);
295
+ if (input.status !== 'Reserved' && input.status !== 'Running'
296
+ && input.status !== 'Succeeded' && input.status !== 'Failed') {
297
+ throw new AIDataWorkflowChildInvocationError(`${location}.status is invalid.`);
298
+ }
299
+ const normalized = Object.freeze({
300
+ schemaVersion: AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION,
301
+ invocationKey: nonEmpty(input.invocationKey, `${location}.invocationKey`),
302
+ childInstanceId: nonEmpty(input.childInstanceId, `${location}.childInstanceId`),
303
+ childRunId: nonEmpty(input.childRunId, `${location}.childRunId`),
304
+ status: input.status,
305
+ });
306
+ assertChildIdentity(normalized, identity, location);
307
+ return normalized;
308
+ }
309
+
310
+ export function normalizeAIDataWorkflowChildTerminalReceipt(
311
+ value: unknown,
312
+ identity: AIDataWorkflowChildInvocationIdentity,
313
+ location = 'childInvocation.terminalReceipt',
314
+ ): AIDataWorkflowChildTerminalReceipt {
315
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
316
+ throw new AIDataWorkflowChildInvocationError(`${location} must be an object.`);
317
+ }
318
+ const status = (value as Readonly<Record<string, unknown>>).status;
319
+ const common = [
320
+ 'schemaVersion', 'invocationKey', 'childInstanceId', 'childRunId', 'definitionRef', 'flowRef',
321
+ 'contractDigest', 'status', 'receiptDigest',
322
+ ];
323
+ const input = record(value, status === 'Succeeded'
324
+ ? [...common, 'output']
325
+ : status === 'Failed'
326
+ ? [...common, 'failureCode', 'failureMessage']
327
+ : common, location);
328
+ schema(input.schemaVersion, `${location}.schemaVersion`);
329
+ if (status !== 'Succeeded' && status !== 'Failed') {
330
+ throw new AIDataWorkflowChildInvocationError(`${location}.status must be terminal.`);
331
+ }
332
+ const commonResult = {
333
+ schemaVersion: AI_DATA_CHILD_INVOCATION_SCHEMA_VERSION,
334
+ invocationKey: nonEmpty(input.invocationKey, `${location}.invocationKey`),
335
+ childInstanceId: nonEmpty(input.childInstanceId, `${location}.childInstanceId`),
336
+ childRunId: nonEmpty(input.childRunId, `${location}.childRunId`),
337
+ definitionRef: prefixed(input.definitionRef, 'resource://', `${location}.definitionRef`) as `resource://${string}`,
338
+ flowRef: prefixed(input.flowRef, 'eager-data-flow://', `${location}.flowRef`) as `eager-data-flow://${string}`,
339
+ contractDigest: nonEmpty(input.contractDigest, `${location}.contractDigest`),
340
+ receiptDigest: nonEmpty(input.receiptDigest, `${location}.receiptDigest`),
341
+ };
342
+ assertChildIdentity(commonResult, identity, location);
343
+ if (status === 'Succeeded') {
344
+ return Object.freeze({
345
+ ...commonResult,
346
+ status,
347
+ output: normalizeFlowClosedObject(input.output, `${location}.output`),
348
+ });
349
+ }
350
+ return Object.freeze({
351
+ ...commonResult,
352
+ status,
353
+ failureCode: nonEmpty(input.failureCode, `${location}.failureCode`),
354
+ failureMessage: nonEmpty(input.failureMessage, `${location}.failureMessage`),
355
+ });
356
+ }
357
+
358
+ function assertChildIdentity(
359
+ value: Readonly<Record<string, unknown>>,
360
+ identity: AIDataWorkflowChildInvocationIdentity,
361
+ location: string,
362
+ ): void {
363
+ const expected: Readonly<Record<string, unknown>> = {
364
+ invocationKey: identity.invocationKey,
365
+ childInstanceId: identity.childInstanceId,
366
+ childRunId: identity.childRunId,
367
+ definitionRef: identity.subflow.definitionRef,
368
+ flowRef: identity.subflow.flowRef,
369
+ contractDigest: identity.subflow.contractDigest,
370
+ };
371
+ for (const [field, expectedValue] of Object.entries(expected)) {
372
+ if (value[field] !== undefined && value[field] !== expectedValue) {
373
+ throw new AIDataWorkflowChildInvocationError(`${location}.${field} does not match the reserved child identity.`);
374
+ }
375
+ }
376
+ }