ai-data-workflow-logic 0.1.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-data-workflow-logic",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "description": "EagerDataFlow-backed AI Data Workflow RunGraph helpers",
6
6
  "type": "module",
@@ -11,11 +11,11 @@
11
11
  "./filesystem": "./src/filesystem.ts"
12
12
  },
13
13
  "dependencies": {
14
- "ai-data-workflow-contract": "0.1.6",
15
- "ai-workflow-contract": "0.1.8",
16
- "ai-workflow-logic": "0.1.9",
17
- "eager-data-flow-contract": "0.1.3",
18
- "eager-data-flow-logic": "0.1.5",
14
+ "ai-data-workflow-contract": "0.1.7",
15
+ "ai-workflow-contract": "0.1.9",
16
+ "ai-workflow-logic": "0.1.11",
17
+ "eager-data-flow-contract": "0.1.4",
18
+ "eager-data-flow-logic": "0.1.6",
19
19
  "flow-step-space-logic": "0.1.1",
20
20
  "task-manager-contract": "0.1.3",
21
21
  "task-manager-logic": "0.1.6",
@@ -21,6 +21,7 @@ import {
21
21
  type AIDataWorkflowNodeInputs,
22
22
  type AIDataWorkflowRunGraph,
23
23
  type AIDataWorkflowRunNode,
24
+ type AIDataWorkflowSubFlowBinding,
24
25
  } from 'ai-data-workflow-contract';
25
26
  import type { EagerDataFlowValue } from 'eager-data-flow-contract';
26
27
 
@@ -239,13 +240,20 @@ function assertResourceRef(value: unknown, location: string): void {
239
240
  }
240
241
  }
241
242
 
243
+ function assertEagerDataFlowRef(value: unknown, location: string): void {
244
+ assertNonEmptyString(value, location);
245
+ if (!value.startsWith('eager-data-flow://') || value.length === 'eager-data-flow://'.length) {
246
+ fail('AI_DATA_CONTROL_FLOW_REF', `${location} must be an exact eager-data-flow:// reference.`);
247
+ }
248
+ }
249
+
242
250
  function assertCapability(capability: AIDataControlCapability, key: string): void {
243
251
  assertNonEmptyString(capability.capabilityId, `capabilities.${key}.capabilityId`);
244
252
  if (capability.capabilityId !== key) {
245
253
  fail('AI_DATA_CONTROL_CAPABILITY_ID', `Capability key ${key} must equal capabilityId ${capability.capabilityId}.`);
246
254
  }
247
- if (capability.tag !== 'TransformNode' && capability.tag !== 'SinkNode') {
248
- fail('AI_DATA_CONTROL_CAPABILITY_TAG', `Capability ${key} must use TransformNode or SinkNode.`);
255
+ if (capability.tag !== 'TransformNode' && capability.tag !== 'SinkNode' && capability.tag !== 'SubFlowNode') {
256
+ fail('AI_DATA_CONTROL_CAPABILITY_TAG', `Capability ${key} must use TransformNode, SinkNode or SubFlowNode.`);
249
257
  }
250
258
  assertNonEmptyString(capability.nodeType, `capabilities.${key}.nodeType`);
251
259
  assertSchemaMap(capability.inputSchemaRefs, `capabilities.${key}.inputSchemaRefs`);
@@ -260,10 +268,36 @@ function assertCapability(capability: AIDataControlCapability, key: string): voi
260
268
  fail('AI_DATA_CONTROL_CAPABILITY_CONFIG', `Capability ${key} fixedConfig uses reserved control keys.`);
261
269
  }
262
270
  if (capability.implementation.kind === 'code') {
271
+ if (capability.tag === 'SubFlowNode') {
272
+ fail('AI_DATA_CONTROL_CAPABILITY_IMPLEMENTATION', `SubFlow capability ${key} must use a subflow implementation.`);
273
+ }
263
274
  assertResourceRef(capability.implementation.codeRef, `capabilities.${key}.implementation.codeRef`);
264
275
  } else if (capability.implementation.kind === 'agent') {
276
+ if (capability.tag === 'SubFlowNode') {
277
+ fail('AI_DATA_CONTROL_CAPABILITY_IMPLEMENTATION', `SubFlow capability ${key} must use a subflow implementation.`);
278
+ }
265
279
  assertResourceRef(capability.implementation.agentDefinitionRef, `capabilities.${key}.implementation.agentDefinitionRef`);
266
280
  assertResourceRef(capability.implementation.taskProofRef, `capabilities.${key}.implementation.taskProofRef`);
281
+ } else if (capability.implementation.kind === 'subflow') {
282
+ if (capability.tag !== 'SubFlowNode' || capability.nodeType !== 'subflow') {
283
+ fail('AI_DATA_CONTROL_CAPABILITY_IMPLEMENTATION', `Subflow implementation ${key} requires SubFlowNode/subflow.`);
284
+ }
285
+ assertResourceRef(capability.implementation.definitionRef, `capabilities.${key}.implementation.definitionRef`);
286
+ assertEagerDataFlowRef(capability.implementation.flowRef, `capabilities.${key}.implementation.flowRef`);
287
+ assertNonEmptyString(capability.implementation.contractDigest, `capabilities.${key}.implementation.contractDigest`);
288
+ assertNonEmptyString(capability.implementation.callGraphDigest, `capabilities.${key}.implementation.callGraphDigest`);
289
+ if (!Array.isArray(capability.implementation.transitiveChildFlowRefs)) {
290
+ fail('AI_DATA_CONTROL_CALL_GRAPH', `capabilities.${key}.implementation.transitiveChildFlowRefs must be an array.`);
291
+ }
292
+ const childRefs = capability.implementation.transitiveChildFlowRefs;
293
+ childRefs.forEach((ref, index) => assertEagerDataFlowRef(
294
+ ref,
295
+ `capabilities.${key}.implementation.transitiveChildFlowRefs[${index}]`,
296
+ ));
297
+ if (new Set(childRefs).size !== childRefs.length
298
+ || childRefs.some((ref, index) => index > 0 && compareCodeUnits(childRefs[index - 1]!, ref) >= 0)) {
299
+ fail('AI_DATA_CONTROL_CALL_GRAPH', `capabilities.${key}.implementation.transitiveChildFlowRefs must be unique and sorted.`);
300
+ }
267
301
  } else {
268
302
  fail('AI_DATA_CONTROL_CAPABILITY_IMPLEMENTATION', `Capability ${key} has an unknown implementation kind.`);
269
303
  }
@@ -516,6 +550,17 @@ function normalizeRevisionOperation(
516
550
  ...(dependsOn === undefined ? {} : { dependsOn }),
517
551
  };
518
552
  }
553
+ if (value.op === 'add-subflow' || value.op === 'rewire-subflow') {
554
+ const record = exactRecord(value, ['op', 'nodeId', 'subflowCapabilityId', 'inputs'], ['dependsOn'], location);
555
+ const dependsOn = normalizeDependsOn(record.dependsOn, `${location}.dependsOn`);
556
+ return {
557
+ op: value.op,
558
+ nodeId: stringField(record, 'nodeId', location),
559
+ subflowCapabilityId: stringField(record, 'subflowCapabilityId', location),
560
+ inputs: normalizeInputs(runtime, record.inputs, `${location}.inputs`),
561
+ ...(dependsOn === undefined ? {} : { dependsOn }),
562
+ };
563
+ }
519
564
  fail('AI_DATA_CONTROL_DECISION_SHAPE', `${location}.op is unsupported.`);
520
565
  }
521
566
 
@@ -836,7 +881,7 @@ function nodeContracts(
836
881
 
837
882
  function validateInputs(
838
883
  runtime: AIDataControlRuntime,
839
- operation: Extract<AIDataControlRevisionOperation, { op: 'add-capability' | 'rewire-capability' }>,
884
+ operation: Exclude<AIDataControlRevisionOperation, { op: 'remove-node' }>,
840
885
  capability: AIDataControlCapability,
841
886
  contracts: ReadonlyMap<string, MutableNodeContract>,
842
887
  operationIndex: number,
@@ -969,6 +1014,7 @@ function capabilityConfig(
969
1014
  if (capability.implementation.kind === 'code') {
970
1015
  return { ...base, src: capability.implementation.codeRef };
971
1016
  }
1017
+ if (capability.implementation.kind === 'subflow') return base;
972
1018
  return {
973
1019
  ...base,
974
1020
  agent: {
@@ -978,8 +1024,24 @@ function capabilityConfig(
978
1024
  };
979
1025
  }
980
1026
 
1027
+ function capabilitySubflowBinding(
1028
+ capability: AIDataControlCapability,
1029
+ catalog: AIDataControlCapabilityCatalog,
1030
+ ): AIDataWorkflowSubFlowBinding | undefined {
1031
+ if (capability.implementation.kind !== 'subflow') return undefined;
1032
+ return {
1033
+ kind: 'autonomous',
1034
+ schemaVersion: 'depa.ai-data-subflow-binding/v1',
1035
+ capabilityId: capability.capabilityId,
1036
+ catalogDigest: catalog.digest,
1037
+ definitionRef: capability.implementation.definitionRef,
1038
+ flowRef: capability.implementation.flowRef,
1039
+ contractDigest: capability.implementation.contractDigest,
1040
+ };
1041
+ }
1042
+
981
1043
  function operationDependencies(
982
- operation: Extract<AIDataControlRevisionOperation, { op: 'add-capability' | 'rewire-capability' }>,
1044
+ operation: Exclude<AIDataControlRevisionOperation, { op: 'remove-node' }>,
983
1045
  ): readonly string[] {
984
1046
  return [...new Set([
985
1047
  ...(operation.dependsOn ?? []),
@@ -998,6 +1060,7 @@ function sameNodeRevision(
998
1060
  inputs: node.inputs,
999
1061
  outputs: node.outputs,
1000
1062
  config: node.config,
1063
+ subflow: node.subflow ?? null,
1001
1064
  dependsOn: node.dependsOn,
1002
1065
  }) === runtime.stableDigest({
1003
1066
  tag: operation.changes.tag ?? node.tag,
@@ -1005,6 +1068,9 @@ function sameNodeRevision(
1005
1068
  inputs: operation.changes.inputs ?? node.inputs,
1006
1069
  outputs: operation.changes.outputs ?? node.outputs,
1007
1070
  config: operation.changes.config ?? node.config,
1071
+ subflow: (Object.prototype.hasOwnProperty.call(operation.changes, 'subflow')
1072
+ ? operation.changes.subflow
1073
+ : node.subflow) ?? null,
1008
1074
  dependsOn: operation.changes.dependsOn ?? node.dependsOn,
1009
1075
  });
1010
1076
  }
@@ -1160,7 +1226,7 @@ function admitNormalizedAIDataControlDecision(
1160
1226
 
1161
1227
  const targets = new Set<string>();
1162
1228
  for (let index = 0; index < input.decision.operations.length; index += 1) {
1163
- const operation = input.decision.operations[index]!;
1229
+ const operation: AIDataControlRevisionOperation = input.decision.operations[index]!;
1164
1230
  if (targets.has(operation.nodeId)) {
1165
1231
  return rejected(runtime, input.decision, [
1166
1232
  feedback('DUPLICATE_TARGET', `Revision targets ${operation.nodeId} more than once.`, {
@@ -1181,7 +1247,7 @@ function admitNormalizedAIDataControlDecision(
1181
1247
  const removed = new Set<string>();
1182
1248
 
1183
1249
  for (let index = 0; index < input.decision.operations.length; index += 1) {
1184
- const operation = input.decision.operations[index]!;
1250
+ const operation: AIDataControlRevisionOperation = input.decision.operations[index]!;
1185
1251
  const existingContract = contracts.get(operation.nodeId);
1186
1252
  if (operation.op === 'remove-node') {
1187
1253
  if (!existingContract || existingContract.removed) {
@@ -1202,13 +1268,15 @@ function admitNormalizedAIDataControlDecision(
1202
1268
  continue;
1203
1269
  }
1204
1270
 
1205
- if (operation.op === 'add-capability' && existingContract) {
1271
+ const isAdd = operation.op === 'add-capability' || operation.op === 'add-subflow';
1272
+ const isRewire = operation.op === 'rewire-capability' || operation.op === 'rewire-subflow';
1273
+ if (isAdd && existingContract) {
1206
1274
  return rejected(runtime, input.decision, [feedback('INVALID_DECISION', `Node ${operation.nodeId} already exists.`, {
1207
1275
  operationIndex: index,
1208
1276
  nodeId: operation.nodeId,
1209
1277
  })]);
1210
1278
  }
1211
- if (operation.op === 'rewire-capability') {
1279
+ if (isRewire) {
1212
1280
  if (!existingContract || existingContract.removed) {
1213
1281
  return rejected(runtime, input.decision, [feedback('UNKNOWN_NODE', `Node ${operation.nodeId} is not active.`, {
1214
1282
  operationIndex: index,
@@ -1223,15 +1291,37 @@ function admitNormalizedAIDataControlDecision(
1223
1291
  }
1224
1292
  }
1225
1293
 
1226
- const capability = input.catalog.capabilities[operation.capabilityId];
1294
+ const capabilityId = operation.op === 'add-subflow' || operation.op === 'rewire-subflow'
1295
+ ? operation.subflowCapabilityId
1296
+ : operation.capabilityId;
1297
+ const capability = input.catalog.capabilities[capabilityId];
1227
1298
  if (!capability) {
1228
- return rejected(runtime, input.decision, [feedback('UNKNOWN_CAPABILITY', `Capability ${operation.capabilityId} is not frozen.`, {
1299
+ return rejected(runtime, input.decision, [feedback('UNKNOWN_CAPABILITY', `Capability ${capabilityId} is not frozen.`, {
1229
1300
  operationIndex: index,
1230
1301
  nodeId: operation.nodeId,
1231
1302
  })]);
1232
1303
  }
1304
+ const expectsSubflow = operation.op === 'add-subflow' || operation.op === 'rewire-subflow';
1305
+ if (expectsSubflow !== (capability.implementation.kind === 'subflow')) {
1306
+ return rejected(runtime, input.decision, [feedback(
1307
+ 'INVALID_DECISION',
1308
+ `Operation ${operation.op} does not match frozen capability ${capabilityId}.`,
1309
+ { operationIndex: index, nodeId: operation.nodeId },
1310
+ )]);
1311
+ }
1233
1312
  const inputProblem = validateInputs(runtime, operation, capability, contracts, index);
1234
1313
  if (inputProblem) return rejected(runtime, input.decision, [inputProblem]);
1314
+ if (capability.implementation.kind === 'subflow') {
1315
+ const parentFlowRef = `eager-data-flow://${input.graph.binding.definition.fqn}` as `eager-data-flow://${string}`;
1316
+ if (capability.implementation.flowRef === parentFlowRef
1317
+ || capability.implementation.transitiveChildFlowRefs.includes(parentFlowRef)) {
1318
+ return rejected(runtime, input.decision, [feedback(
1319
+ 'RECURSIVE_SUBFLOW',
1320
+ `Subflow capability ${capability.capabilityId} would recurse into ${parentFlowRef}.`,
1321
+ { operationIndex: index, nodeId: operation.nodeId },
1322
+ )]);
1323
+ }
1324
+ }
1235
1325
  const dependsOn = operationDependencies(operation);
1236
1326
  for (const dependency of dependsOn) {
1237
1327
  const dependencyContract = contracts.get(dependency);
@@ -1242,6 +1332,7 @@ function admitNormalizedAIDataControlDecision(
1242
1332
  })]);
1243
1333
  }
1244
1334
  }
1335
+ const subflow = capabilitySubflowBinding(capability, input.catalog);
1245
1336
  const nodeFacts = {
1246
1337
  tag: capability.tag,
1247
1338
  nodeType: capability.nodeType,
@@ -1250,16 +1341,16 @@ function admitNormalizedAIDataControlDecision(
1250
1341
  config: capabilityConfig(capability, input.catalog),
1251
1342
  dependsOn,
1252
1343
  };
1253
- if (operation.op === 'add-capability') {
1344
+ if (isAdd) {
1254
1345
  patchOperations.push({
1255
1346
  op: 'add-node',
1256
- node: { id: operation.nodeId, ...nodeFacts },
1347
+ node: { id: operation.nodeId, ...nodeFacts, ...(subflow ? { subflow } : {}) },
1257
1348
  });
1258
1349
  } else {
1259
1350
  patchOperations.push({
1260
1351
  op: 'update-node',
1261
1352
  nodeId: operation.nodeId,
1262
- changes: nodeFacts,
1353
+ changes: { ...nodeFacts, subflow: subflow ?? null },
1263
1354
  });
1264
1355
  }
1265
1356
  contracts.set(operation.nodeId, {
@@ -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
+ }
package/src/filesystem.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type {
4
+ AIDataWorkflowChildInvocationIdentity,
5
+ AIDataWorkflowChildInvocationRuntimePort,
6
+ AIDataWorkflowChildTerminalReceipt,
4
7
  AIDataWorkflowRunCheckpoint,
5
8
  AIDataWorkflowRunGraph,
6
9
  } from 'ai-data-workflow-contract';
@@ -12,10 +15,13 @@ import type {
12
15
  AIWorkflowReusePolicy,
13
16
  FlowClosedValue,
14
17
  } from 'ai-workflow-contract';
18
+ import type { EagerDataFlowModuleImporter } from 'eager-data-flow-logic';
15
19
  import type {
16
- EagerDataFlowModuleImporter,
17
- } from 'eager-data-flow-logic';
18
- import type { EagerDataFlowDurableNodeState } from 'eager-data-flow-contract';
20
+ EagerDataFlowDurableNodeState,
21
+ EagerDataFlowExecutable,
22
+ EagerDataFlowExecutableRegistry,
23
+ EagerDataFlowRecord,
24
+ } from 'eager-data-flow-contract';
19
25
  import {
20
26
  createFilesystemCodeResolver,
21
27
  materializeEagerDataFlowPlan,
@@ -30,6 +36,7 @@ import {
30
36
  } from 'ai-workflow-logic/checkpoint-filesystem';
31
37
  import {
32
38
  createInitialRunStepExtensions,
39
+ FlowRunCheckpointVersionConflictError,
33
40
  normalizeAIWorkflowProfileDurableState,
34
41
  normalizeFlowClosedObject,
35
42
  normalizeFlowClosedValue,
@@ -45,14 +52,27 @@ import {
45
52
  } from 'work-ctrl-flow-logic';
46
53
  import {
47
54
  commitAIDataWorkflowCheckpoint,
55
+ acceptAIDataWorkflowChildTerminalReceipt,
56
+ createAIDataWorkflowChildInvocationIdentity,
48
57
  createAIDataWorkflowCheckpoint,
49
58
  createAIDataWorkflowRunGraph,
50
59
  createAIDataWorkflowAgentNodeRuntimeBinder,
51
60
  loadAIDataWorkflowCheckpoint,
52
61
  loadAIDataWorkflowSources,
62
+ normalizeAIDataWorkflowChildInvocationObservation,
63
+ normalizeAIDataWorkflowChildTerminalReceipt,
64
+ projectAIDataWorkflowRunGraphPlan,
53
65
  recordAIDataWorkflowNodeResult,
66
+ recordAIDataWorkflowChildFreezeReceipt,
67
+ reserveAIDataWorkflowChildInvocation,
54
68
  } from './index.js';
55
69
 
70
+ export interface MaterializedAIDataWorkflowChildFlow {
71
+ readonly definitionRef?: `resource://${string}`;
72
+ readonly contractDigest?: string;
73
+ readonly executable: EagerDataFlowExecutable<any>;
74
+ }
75
+
56
76
  export interface MaterializedAIDataWorkflowRuntime {
57
77
  readonly supportRoot: string;
58
78
  readonly workflow: AIDataWorkflowRuntime;
@@ -60,6 +80,15 @@ export interface MaterializedAIDataWorkflowRuntime {
60
80
  readonly extensionCodecs?: DefinitionStepAssemblyRuntime['extensionCodecs'];
61
81
  readonly agentEffects?: AIAgentHostEffectPort;
62
82
  readonly agentTaskProofs?: Readonly<Record<string, FrozenAIAgentTaskBinding>>;
83
+ /** Exact admitted child definitions keyed by their eager-data-flow id. */
84
+ readonly childFlows?: Readonly<Record<string, MaterializedAIDataWorkflowChildFlow>>;
85
+ readonly childInvocations?: AIDataWorkflowChildInvocationRuntimePort;
86
+ /** Read-only lifecycle observation/fault-injection seam; it owns no parent or child facts. */
87
+ readonly childInvocationObserver?: (event: {
88
+ readonly phase: 'settled-before-parent-accept';
89
+ readonly identity: AIDataWorkflowChildInvocationIdentity;
90
+ readonly receipt: AIDataWorkflowChildTerminalReceipt;
91
+ }) => void | Promise<void>;
63
92
  readonly checkpointObserver?: (event: {
64
93
  readonly checkpoint: AIDataWorkflowRunCheckpoint;
65
94
  readonly nodeId: string;
@@ -209,43 +238,56 @@ async function executeAIDataCheckpoint(
209
238
  nodeId: string,
210
239
  phase: 'before' | 'after' | 'failed',
211
240
  ): Promise<void> => transition(async () => {
212
- const owner = await loadAIDataWorkflowCheckpoint(checkpointRuntime, {
213
- instanceId: checkpoint.instanceId,
214
- runId: checkpoint.runId,
215
- });
216
- if (!owner) throw new Error(`AI Data Workflow checkpoint ${checkpoint.runId} disappeared during node transition.`);
217
- accepted = owner;
218
- graph = { ...owner.profile.runGraph, binding };
219
- const nextGraph = updateGraph(graph);
220
- const nextStatus = graphCheckpointStatus(nextGraph);
221
- accepted = await commitAIDataWorkflowCheckpoint(checkpointRuntime, {
222
- expectedVersion: accepted.version,
223
- checkpoint: {
224
- ...accepted,
225
- version: accepted.version + 1,
226
- state: { status: nextStatus, generation: nextGraph.currentGeneration },
227
- output: graphCheckpointOutput(nextGraph, nextStatus),
228
- controllerSidecars: { status: nextStatus },
229
- nodeSidecars: nodeStatuses(nextGraph),
230
- profile: { ...accepted.profile, runGraph: durableGraph(nextGraph) },
231
- },
232
- });
233
- graph = { ...accepted.profile.runGraph, binding };
234
- if (runtime.checkpointObserver) {
241
+ for (let attempt = 0; attempt < 8; attempt += 1) {
242
+ const owner = await loadAIDataWorkflowCheckpoint(checkpointRuntime, {
243
+ instanceId: checkpoint.instanceId,
244
+ runId: checkpoint.runId,
245
+ });
246
+ if (!owner) throw new Error(`AI Data Workflow checkpoint ${checkpoint.runId} disappeared during node transition.`);
247
+ accepted = owner;
248
+ graph = { ...owner.profile.runGraph, binding };
249
+ const nextGraph = updateGraph(graph);
250
+ if (nextGraph === graph) return;
251
+ const nextStatus = graphCheckpointStatus(nextGraph);
235
252
  try {
236
- await runtime.checkpointObserver({ checkpoint: accepted, nodeId, phase });
253
+ accepted = await commitAIDataWorkflowCheckpoint(checkpointRuntime, {
254
+ expectedVersion: accepted.version,
255
+ checkpoint: {
256
+ ...accepted,
257
+ version: accepted.version + 1,
258
+ state: { status: nextStatus, generation: nextGraph.currentGeneration },
259
+ output: graphCheckpointOutput(nextGraph, nextStatus),
260
+ controllerSidecars: { status: nextStatus },
261
+ nodeSidecars: nodeStatuses(nextGraph),
262
+ profile: { ...accepted.profile, runGraph: durableGraph(nextGraph) },
263
+ },
264
+ });
237
265
  } catch (cause) {
238
- throw new AIDataCheckpointObserverError(cause);
266
+ if (cause instanceof FlowRunCheckpointVersionConflictError) continue;
267
+ throw cause;
239
268
  }
269
+ graph = { ...accepted.profile.runGraph, binding };
270
+ if (runtime.checkpointObserver) {
271
+ try {
272
+ await runtime.checkpointObserver({ checkpoint: accepted, nodeId, phase });
273
+ } catch (cause) {
274
+ throw new AIDataCheckpointObserverError(cause);
275
+ }
276
+ }
277
+ return;
240
278
  }
279
+ throw new Error(`AI Data Workflow node ${nodeId} checkpoint CAS did not converge.`);
241
280
  });
242
281
  let output: unknown;
243
282
  try {
244
- const executable = await materializeEagerDataFlowPlan(binding.definition, {
283
+ const executionPlan = projectAIDataWorkflowRunGraphPlan(graph);
284
+ const executable = await materializeEagerDataFlowPlan(executionPlan, {
245
285
  resolveCode: createFilesystemCodeResolver(runtime.importModule),
286
+ subflows: createMaterializedChildRegistry(runtime, () => graph),
246
287
  });
247
288
  output = await executable.invoke(runtime.workflow, invocationInput, {
248
289
  completedNodes: completedEagerNodes(graph),
290
+ invokeSubFlow: createMaterializedChildInvoker(runtime, () => graph, commitGraph),
249
291
  ...(runtime.agentEffects ? {
250
292
  bindNode: ((binder) => (nodeRuntime: unknown, identity: { readonly nodeId: string }) => binder(
251
293
  nodeRuntime as AIDataWorkflowRuntime,
@@ -264,6 +306,14 @@ async function executeAIDataCheckpoint(
264
306
  return commitGraph((current) => {
265
307
  const node = current.nodes[nodeId];
266
308
  if (!node) throw new Error(`AI Data Workflow node ${nodeId} does not exist in the durable graph.`);
309
+ if (node.tag === 'SubFlowNode' && node.subflow?.kind === 'autonomous') {
310
+ const identity = node.childInvocation ?? createAIDataWorkflowChildInvocationIdentity({
311
+ graph: current,
312
+ parentInstanceId: checkpoint.instanceId,
313
+ nodeId,
314
+ });
315
+ return reserveAIDataWorkflowChildInvocation({ graph: current, identity });
316
+ }
267
317
  return recordAIDataWorkflowNodeResult(current, nodeId, {
268
318
  nodeId,
269
319
  generation: node.generation,
@@ -275,6 +325,7 @@ async function executeAIDataCheckpoint(
275
325
  return commitGraph((current) => {
276
326
  const node = current.nodes[settlement.nodeId];
277
327
  if (!node) throw new Error(`AI Data Workflow node ${settlement.nodeId} does not exist in the durable graph.`);
328
+ if (node.tag === 'SubFlowNode' && node.childTerminalReceipt) return current;
278
329
  return recordAIDataWorkflowNodeResult(current, settlement.nodeId, {
279
330
  nodeId: settlement.nodeId,
280
331
  generation: node.generation,
@@ -284,10 +335,13 @@ async function executeAIDataCheckpoint(
284
335
  });
285
336
  }, settlement.nodeId, 'after');
286
337
  },
287
- onNodeFailure: ({ nodeId }) => {
338
+ onNodeFailure: ({ nodeId, cause }) => {
288
339
  return commitGraph((current) => {
289
340
  const node = current.nodes[nodeId];
290
341
  if (!node) throw new Error(`AI Data Workflow node ${nodeId} does not exist in the durable graph.`);
342
+ if (cause instanceof AIDataChildInvocationSuspendedError
343
+ || cause instanceof AIDataChildInvocationTerminalFailure
344
+ || findCheckpointObserverError(cause)) return current;
291
345
  return recordAIDataWorkflowNodeResult(current, nodeId, {
292
346
  nodeId,
293
347
  generation: node.generation,
@@ -310,6 +364,125 @@ async function executeAIDataCheckpoint(
310
364
  return { checkpoint: accepted, graph };
311
365
  }
312
366
 
367
+ class AIDataChildInvocationSuspendedError extends Error {
368
+ constructor(nodeId: string, status: string) {
369
+ super(`AI Data child invocation ${nodeId} is ${status}; continue the parent checkpoint to resume it.`);
370
+ this.name = 'AIDataChildInvocationSuspendedError';
371
+ }
372
+ }
373
+
374
+ class AIDataChildInvocationTerminalFailure extends Error {
375
+ constructor(nodeId: string, code: string, message: string) {
376
+ super(`AI Data child invocation ${nodeId} failed (${code}): ${message}`);
377
+ this.name = 'AIDataChildInvocationTerminalFailure';
378
+ }
379
+ }
380
+
381
+ function createMaterializedChildRegistry(
382
+ runtime: MaterializedAIDataWorkflowRuntime,
383
+ currentGraph: () => AIDataWorkflowRunGraph,
384
+ ): EagerDataFlowExecutableRegistry<any> {
385
+ const registry: Record<string, EagerDataFlowExecutable<any>> = {};
386
+ for (const nodeId of currentGraph().declarationOrder) {
387
+ const node = currentGraph().nodes[nodeId];
388
+ if (!node || node.status === 'Removed' || node.tag !== 'SubFlowNode' || !node.subflow) continue;
389
+ const flowId = node.subflow.flowRef.slice('eager-data-flow://'.length);
390
+ const admitted = runtime.childFlows?.[flowId];
391
+ if (!admitted || admitted.executable.plan.fqn !== flowId) {
392
+ throw new Error(`AI Data child flow ${flowId} is not present in the admitted executable registry.`);
393
+ }
394
+ if (node.subflow.kind === 'autonomous' && (admitted.definitionRef !== node.subflow.definitionRef
395
+ || admitted.contractDigest !== node.subflow.contractDigest)) {
396
+ throw new Error(`AI Data child flow ${flowId} does not match its frozen definition and contract.`);
397
+ }
398
+ if (node.subflow.kind === 'autonomous' && !runtime.childInvocations) {
399
+ throw new Error(`AI Data autonomous child flow ${flowId} requires a child invocation runtime port.`);
400
+ }
401
+ registry[flowId] = admitted.executable;
402
+ }
403
+ return Object.freeze(registry);
404
+ }
405
+
406
+ function createMaterializedChildInvoker(
407
+ runtime: MaterializedAIDataWorkflowRuntime,
408
+ currentGraph: () => AIDataWorkflowRunGraph,
409
+ commitGraph: (
410
+ updateGraph: (current: AIDataWorkflowRunGraph) => AIDataWorkflowRunGraph,
411
+ nodeId: string,
412
+ phase: 'before' | 'after' | 'failed',
413
+ ) => Promise<void>,
414
+ ) {
415
+ return async (
416
+ workflow: AIDataWorkflowRuntime,
417
+ invocation: { readonly nodeId: string; readonly flowRef: string },
418
+ input: Readonly<EagerDataFlowRecord>,
419
+ options: { readonly signal: AbortSignal },
420
+ ): Promise<EagerDataFlowRecord> => {
421
+ const node = currentGraph().nodes[invocation.nodeId];
422
+ if (!node || node.tag !== 'SubFlowNode' || node.subflow?.flowRef !== invocation.flowRef) {
423
+ throw new Error(`AI Data child dispatch ${invocation.nodeId} does not match the current RunGraph.`);
424
+ }
425
+ const flowId = invocation.flowRef.slice('eager-data-flow://'.length);
426
+ const admitted = runtime.childFlows?.[flowId];
427
+ if (!admitted) throw new Error(`AI Data child flow ${flowId} is not admitted.`);
428
+ if (node.subflow.kind === 'declared') {
429
+ return admitted.executable.invoke(workflow, input, { signal: options.signal });
430
+ }
431
+ if (!node.childInvocation || !runtime.childInvocations) {
432
+ throw new Error(`AI Data child node ${invocation.nodeId} was not reserved before dispatch.`);
433
+ }
434
+ const identity = node.childInvocation;
435
+ let freeze = node.childFreezeReceipt;
436
+ if (!freeze) {
437
+ freeze = await runtime.childInvocations.resolveAndFreeze({ identity });
438
+ await commitGraph(
439
+ (graph) => recordAIDataWorkflowChildFreezeReceipt({ graph, nodeId: node.id, receipt: freeze! }),
440
+ node.id,
441
+ 'before',
442
+ );
443
+ freeze = currentGraph().nodes[node.id].childFreezeReceipt;
444
+ }
445
+ if (!freeze) throw new Error(`AI Data child node ${node.id} freeze receipt was not committed.`);
446
+ let observation = normalizeAIDataWorkflowChildInvocationObservation(
447
+ await runtime.childInvocations.startOrContinue({
448
+ identity,
449
+ freeze,
450
+ input: normalizeFlowClosedObject(input, `aiData.child.${node.id}.input`),
451
+ }),
452
+ identity,
453
+ );
454
+ if (observation.status === 'Reserved' || observation.status === 'Running') {
455
+ observation = normalizeAIDataWorkflowChildInvocationObservation(
456
+ await runtime.childInvocations.load({ identity, freeze }),
457
+ identity,
458
+ );
459
+ }
460
+ if (observation.status === 'Reserved' || observation.status === 'Running') {
461
+ throw new AIDataChildInvocationSuspendedError(node.id, observation.status);
462
+ }
463
+ const receipt = normalizeAIDataWorkflowChildTerminalReceipt(
464
+ await runtime.childInvocations.settle({ identity, freeze, terminalStatus: observation.status }),
465
+ identity,
466
+ );
467
+ if (runtime.childInvocationObserver) {
468
+ try {
469
+ await runtime.childInvocationObserver({ phase: 'settled-before-parent-accept', identity, receipt });
470
+ } catch (cause) {
471
+ throw new AIDataCheckpointObserverError(cause);
472
+ }
473
+ }
474
+ await commitGraph(
475
+ (graph) => acceptAIDataWorkflowChildTerminalReceipt({ graph, nodeId: node.id, receipt }),
476
+ node.id,
477
+ receipt.status === 'Succeeded' ? 'after' : 'failed',
478
+ );
479
+ if (receipt.status === 'Failed') {
480
+ throw new AIDataChildInvocationTerminalFailure(node.id, receipt.failureCode, receipt.failureMessage);
481
+ }
482
+ return receipt.output as EagerDataFlowRecord;
483
+ };
484
+ }
485
+
313
486
  class AIDataCheckpointObserverError extends Error {
314
487
  constructor(public override readonly cause: unknown) {
315
488
  super('AI Data checkpoint observer interrupted execution.');
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ import type {
11
11
  AIDataWorkflowRunNode,
12
12
  AIDataWorkflowRunNodeStatus,
13
13
  AIDataWorkflowSemanticInput,
14
+ AIDataWorkflowSubFlowBinding,
15
+ AIDataWorkflowChildInvocationIdentity,
16
+ AIDataWorkflowChildTerminalReceipt,
14
17
  } from 'ai-data-workflow-contract';
15
18
  import type {
16
19
  AIDataWorkflowDefinitionBinding,
@@ -63,6 +66,7 @@ import { assertFrozenAIAgentTaskBinding } from 'ai-workflow-logic/run-freeze';
63
66
 
64
67
  export * from './holon-task.js';
65
68
  export * from './autonomous-control.js';
69
+ export * from './child-invocation.js';
66
70
 
67
71
  export interface CreateAIDataWorkflowCheckpointInput {
68
72
  readonly instanceId: string;
@@ -287,12 +291,20 @@ function cloneInvalidation(invalidation: AIDataWorkflowInvalidation): AIDataWork
287
291
  return { ...invalidation };
288
292
  }
289
293
 
294
+ function cloneSubflow(binding: AIDataWorkflowSubFlowBinding): AIDataWorkflowSubFlowBinding {
295
+ return { ...binding };
296
+ }
297
+
290
298
  function cloneNode(node: AIDataWorkflowRunNode): AIDataWorkflowRunNode {
291
299
  return {
292
300
  ...node,
293
301
  inputs: cloneRecord(node.inputs),
294
302
  outputs: [...node.outputs],
295
303
  config: cloneRecord(node.config),
304
+ ...(node.subflow ? { subflow: cloneSubflow(node.subflow) } : {}),
305
+ ...(node.childInvocation ? { childInvocation: cloneChildInvocation(node.childInvocation) } : {}),
306
+ ...(node.childFreezeReceipt ? { childFreezeReceipt: { ...node.childFreezeReceipt } } : {}),
307
+ ...(node.childTerminalReceipt ? { childTerminalReceipt: cloneChildTerminalReceipt(node.childTerminalReceipt) } : {}),
296
308
  dependsOn: [...node.dependsOn],
297
309
  dependents: [...node.dependents],
298
310
  reusePolicy: { ...node.reusePolicy },
@@ -302,6 +314,16 @@ function cloneNode(node: AIDataWorkflowRunNode): AIDataWorkflowRunNode {
302
314
  };
303
315
  }
304
316
 
317
+ function cloneChildInvocation(identity: AIDataWorkflowChildInvocationIdentity): AIDataWorkflowChildInvocationIdentity {
318
+ return { ...identity, subflow: { ...identity.subflow } };
319
+ }
320
+
321
+ function cloneChildTerminalReceipt(receipt: AIDataWorkflowChildTerminalReceipt): AIDataWorkflowChildTerminalReceipt {
322
+ return receipt.status === 'Succeeded'
323
+ ? { ...receipt, output: { ...receipt.output } }
324
+ : { ...receipt };
325
+ }
326
+
305
327
  function cloneNodeResult(result: AIWorkflowNodeResult): AIWorkflowNodeResult {
306
328
  return {
307
329
  ...result,
@@ -326,7 +348,27 @@ function cloneGraph(graph: AIDataWorkflowRunGraph): AIDataWorkflowRunGraph {
326
348
  declarationOrder: [...graph.declarationOrder],
327
349
  patchHistory: graph.patchHistory.map((record) => ({
328
350
  ...record,
329
- operations: record.operations.map((operation) => ({ ...operation })),
351
+ operations: record.operations.map((operation) => {
352
+ if (operation.op === 'add-node') {
353
+ return {
354
+ ...operation,
355
+ node: {
356
+ ...operation.node,
357
+ ...(operation.node.subflow ? { subflow: cloneSubflow(operation.node.subflow) } : {}),
358
+ },
359
+ };
360
+ }
361
+ if (operation.op === 'update-node') {
362
+ return {
363
+ ...operation,
364
+ changes: {
365
+ ...operation.changes,
366
+ ...(operation.changes.subflow ? { subflow: cloneSubflow(operation.changes.subflow) } : {}),
367
+ },
368
+ };
369
+ }
370
+ return { ...operation };
371
+ }),
330
372
  })),
331
373
  invalidations: graph.invalidations.map(cloneInvalidation),
332
374
  };
@@ -354,6 +396,40 @@ function nodeConfig(node: EagerDataFlowNodePlan): AIDataWorkflowNodeConfig {
354
396
  return {};
355
397
  }
356
398
 
399
+ function nodeSubflow(node: EagerDataFlowNodePlan): AIDataWorkflowSubFlowBinding | undefined {
400
+ if (node.tag !== 'SubFlowNode') return undefined;
401
+ return {
402
+ kind: 'declared',
403
+ flowRef: node.flow as `eager-data-flow://${string}`,
404
+ };
405
+ }
406
+
407
+ function assertSubflowBinding(
408
+ tag: AIDataWorkflowNodeTag,
409
+ binding: AIDataWorkflowSubFlowBinding | undefined,
410
+ nodeId: string,
411
+ ): void {
412
+ if (tag === 'SubFlowNode' && !binding) {
413
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow SubFlowNode ${nodeId} requires an exact subflow binding`);
414
+ }
415
+ if (tag !== 'SubFlowNode' && binding) {
416
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow node ${nodeId} cannot carry a subflow binding`);
417
+ }
418
+ if (!binding) return;
419
+ if (!binding.flowRef.startsWith('eager-data-flow://') || binding.flowRef.length === 'eager-data-flow://'.length) {
420
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow SubFlowNode ${nodeId} has an invalid flow reference`);
421
+ }
422
+ if (binding.kind === 'autonomous' && (
423
+ binding.schemaVersion !== 'depa.ai-data-subflow-binding/v1'
424
+ || !binding.capabilityId
425
+ || !binding.catalogDigest
426
+ || !binding.contractDigest
427
+ || !binding.definitionRef.startsWith('resource://')
428
+ )) {
429
+ throw new AIDataWorkflowRunGraphError(`AI Data Workflow SubFlowNode ${nodeId} has an invalid autonomous binding`);
430
+ }
431
+ }
432
+
357
433
  function configReusePolicy(config: AIDataWorkflowNodeConfig): AIWorkflowReusePolicy | undefined {
358
434
  const raw = config.reuse_policy ?? config.reusePolicy;
359
435
  return raw === 'semantic-hash' || raw === 'never' ? raw : undefined;
@@ -548,10 +624,12 @@ export function createAIDataWorkflowRunGraph(
548
624
  inputs: nodeInputs(planNode),
549
625
  outputs: nodeOutputs(planNode),
550
626
  config,
627
+ ...(nodeSubflow(planNode) ? { subflow: nodeSubflow(planNode) } : {}),
551
628
  dependsOn: dependencies.get(planNode.id) ?? [],
552
629
  dependents: [],
553
630
  reusePolicy,
554
631
  };
632
+ assertSubflowBinding(runNode.tag, runNode.subflow, runNode.id);
555
633
  return [planNode.id, runNode];
556
634
  }),
557
635
  );
@@ -574,6 +652,96 @@ export function createAIDataWorkflowRunGraph(
574
652
  return graph;
575
653
  }
576
654
 
655
+ function isRunGraphPortReference(value: AIDataWorkflowNodeInputs[string]): value is import('eager-data-flow-contract').FlowPortReference {
656
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value)
657
+ && typeof (value as { ref?: unknown }).ref === 'string'
658
+ && typeof (value as { nodeId?: unknown }).nodeId === 'string'
659
+ && typeof (value as { port?: unknown }).port === 'string');
660
+ }
661
+
662
+ /** Projects the current admitted RunGraph, including autonomous nodes, into the canonical Eager executor plan. */
663
+ export function projectAIDataWorkflowRunGraphPlan(graph: AIDataWorkflowRunGraph): EagerDataFlowAuthoringPlan {
664
+ const activeOrder = graph.declarationOrder.filter((nodeId) => graph.nodes[nodeId]?.status !== 'Removed');
665
+ const nodePlans = activeOrder.map((nodeId): EagerDataFlowNodePlan => {
666
+ const node = graph.nodes[nodeId];
667
+ const base = graph.binding.definition.nodeById[nodeId]
668
+ ?? graph.binding.definition.nodes.find((candidate) => candidate.id === nodeId);
669
+ const dataDependencies = new Set(Object.values(node.inputs).flatMap((binding) => (
670
+ isRunGraphPortReference(binding) ? [binding.nodeId] : []
671
+ )));
672
+ const waitFor = node.dependsOn
673
+ .filter((dependency) => !dataDependencies.has(dependency))
674
+ .map((dependency) => ({ ref: `flow-node://#${dependency}`, nodeId: dependency }));
675
+ const common = { id: node.id, declarationIndex: node.declarationIndex, waitFor };
676
+ if (node.tag === 'EntryNode') return { ...common, tag: 'EntryNode', outputs: [...node.outputs] };
677
+ if (node.tag === 'ReturnNode') {
678
+ return { ...common, tag: 'ReturnNode', inputs: { ...node.inputs } } as EagerDataFlowNodePlan;
679
+ }
680
+ if (node.tag === 'SubFlowNode') {
681
+ if (!node.subflow) throw new AIDataWorkflowRunGraphError(`SubFlowNode ${node.id} has no durable child binding.`);
682
+ return {
683
+ ...common,
684
+ tag: 'SubFlowNode',
685
+ flow: node.subflow.flowRef,
686
+ inputs: { ...node.inputs },
687
+ outputs: [...node.outputs],
688
+ } as EagerDataFlowNodePlan;
689
+ }
690
+ if (node.tag !== 'TransformNode' && node.tag !== 'SinkNode') {
691
+ throw new AIDataWorkflowRunGraphError(`RunGraph node ${node.id} has unsupported executable tag ${node.tag}.`);
692
+ }
693
+ const configuredSource = typeof node.config.src === 'string' ? node.config.src : undefined;
694
+ const configuredImpl = typeof node.config.impl === 'string' ? node.config.impl : undefined;
695
+ const baseExecutable = base && (base.tag === 'TransformNode' || base.tag === 'SinkNode') ? base : undefined;
696
+ const executable = {
697
+ ...(configuredSource ?? baseExecutable?.src ? { src: configuredSource ?? baseExecutable?.src } : {}),
698
+ ...(configuredImpl ?? baseExecutable?.impl ? { impl: configuredImpl ?? baseExecutable?.impl } : {}),
699
+ };
700
+ return {
701
+ ...common,
702
+ tag: node.tag,
703
+ inputs: { ...node.inputs },
704
+ ...(node.tag === 'TransformNode' ? { outputs: [...node.outputs] } : {}),
705
+ ...executable,
706
+ config: { ...node.config },
707
+ } as EagerDataFlowNodePlan;
708
+ });
709
+ const nodeById = Object.fromEntries(nodePlans.map((node) => [node.id, node]));
710
+ const dataEdges = nodePlans.flatMap((node) => node.tag === 'EntryNode' ? [] : Object.entries(node.inputs).flatMap(
711
+ ([input, binding]) => isRunGraphPortReference(binding) ? [{
712
+ kind: 'data' as const,
713
+ fromNodeId: binding.nodeId,
714
+ fromPort: binding.port,
715
+ toNodeId: node.id,
716
+ toInput: input,
717
+ ref: binding.ref,
718
+ }] : [],
719
+ ));
720
+ const waitForEdges = nodePlans.flatMap((node) => node.waitFor.map((reference) => ({
721
+ kind: 'waitFor' as const,
722
+ fromNodeId: reference.nodeId,
723
+ toNodeId: node.id,
724
+ ref: reference.ref,
725
+ })));
726
+ const subflowEdges = nodePlans.flatMap((node) => node.tag === 'SubFlowNode' ? [{
727
+ kind: 'subflow' as const,
728
+ fromFlowId: graph.binding.definition.fqn,
729
+ nodeId: node.id,
730
+ targetFlowId: node.flow.slice('eager-data-flow://'.length),
731
+ ref: node.flow,
732
+ }] : []);
733
+ return {
734
+ ...graph.binding.definition,
735
+ nodes: nodePlans,
736
+ nodeById,
737
+ declarationOrder: activeOrder,
738
+ dataEdges,
739
+ waitForEdges,
740
+ subflowEdges,
741
+ diagnostics: [],
742
+ };
743
+ }
744
+
577
745
  function makePatchNode(
578
746
  spec: AIDataWorkflowGraphPatchNodeSpec,
579
747
  generation: number,
@@ -588,6 +756,8 @@ function makePatchNode(
588
756
  platformDefaultPolicy,
589
757
  });
590
758
  assertReusePolicyAuthority(reusePolicy, nodeType, config, platformDefaultPolicy, spec.id);
759
+ const subflow = spec.subflow ? cloneSubflow(spec.subflow) : undefined;
760
+ assertSubflowBinding(tag, subflow, spec.id);
591
761
  return {
592
762
  id: spec.id,
593
763
  tag,
@@ -598,6 +768,7 @@ function makePatchNode(
598
768
  inputs: cloneRecord(spec.inputs),
599
769
  outputs: [...(spec.outputs ?? [])],
600
770
  config,
771
+ ...(subflow ? { subflow } : {}),
601
772
  dependsOn: uniqueStrings(spec.dependsOn ?? []),
602
773
  dependents: [],
603
774
  reusePolicy,
@@ -663,6 +834,9 @@ function applyInvalidations(
663
834
  const {
664
835
  result: _staleResult,
665
836
  semanticFingerprint: _staleFingerprint,
837
+ childInvocation: _staleChildInvocation,
838
+ childFreezeReceipt: _staleChildFreezeReceipt,
839
+ childTerminalReceipt: _staleChildTerminalReceipt,
666
840
  ...currentFacts
667
841
  } = node;
668
842
  nextNodes = {
@@ -783,6 +957,13 @@ export function applyAIDataWorkflowGraphPatch(
783
957
  );
784
958
  }
785
959
  const config = operation.changes.config ? cloneRecord(operation.changes.config) : existing.config;
960
+ const tag = operation.changes.tag ?? existing.tag;
961
+ const subflow = Object.prototype.hasOwnProperty.call(operation.changes, 'subflow')
962
+ ? operation.changes.subflow === null || operation.changes.subflow === undefined
963
+ ? undefined
964
+ : cloneSubflow(operation.changes.subflow)
965
+ : existing.subflow;
966
+ assertSubflowBinding(tag, subflow, existing.id);
786
967
  const nodeType = operation.changes.nodeType ?? existing.nodeType;
787
968
  const dependsOn = operation.changes.dependsOn
788
969
  ? uniqueStrings(operation.changes.dependsOn)
@@ -798,17 +979,26 @@ export function applyAIDataWorkflowGraphPatch(
798
979
  : existing.reusePolicy
799
980
  );
800
981
  assertReusePolicyAuthority(reusePolicy, nodeType, config, graph.platformDefaultPolicy, existing.id);
982
+ const { subflow: _existingSubflow, ...existingWithoutSubflow } = existing;
983
+ const {
984
+ childInvocation: _staleChildInvocation,
985
+ childFreezeReceipt: _staleChildFreezeReceipt,
986
+ childTerminalReceipt: _staleChildTerminalReceipt,
987
+ ...existingWithoutChild
988
+ } = existingWithoutSubflow;
989
+ const { subflow: _changedSubflow, ...changesWithoutSubflow } = operation.changes;
801
990
  nodes = {
802
991
  ...nodes,
803
992
  [existing.id]: {
804
- ...existing,
805
- ...operation.changes,
806
- tag: operation.changes.tag ?? existing.tag,
993
+ ...existingWithoutChild,
994
+ ...changesWithoutSubflow,
995
+ tag,
807
996
  nodeType,
808
997
  generation,
809
998
  inputs: operation.changes.inputs ? cloneRecord(operation.changes.inputs) : existing.inputs,
810
999
  outputs: operation.changes.outputs ? [...operation.changes.outputs] : existing.outputs,
811
1000
  config,
1001
+ ...(subflow ? { subflow } : {}),
812
1002
  dependsOn,
813
1003
  reusePolicy,
814
1004
  },
@@ -834,6 +1024,9 @@ export function applyAIDataWorkflowGraphPatch(
834
1024
  result: _staleResult,
835
1025
  semanticFingerprint: _staleFingerprint,
836
1026
  invalidatedBy: _activeInvalidation,
1027
+ childInvocation: _staleChildInvocation,
1028
+ childFreezeReceipt: _staleChildFreezeReceipt,
1029
+ childTerminalReceipt: _staleChildTerminalReceipt,
837
1030
  ...currentFacts
838
1031
  } = existing;
839
1032
  nodes = {