@ankhorage/flow 0.0.0-stage → 0.5.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.
Files changed (164) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +19 -2
  4. package/dist/ai.d.ts +7 -0
  5. package/dist/ai.d.ts.map +1 -0
  6. package/dist/ai.js +7 -0
  7. package/dist/ai.js.map +1 -0
  8. package/dist/execution.d.ts +5 -0
  9. package/dist/execution.d.ts.map +1 -0
  10. package/dist/execution.js +5 -0
  11. package/dist/execution.js.map +1 -0
  12. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.d.ts +4 -0
  13. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.d.ts.map +1 -0
  14. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.js +219 -0
  15. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.js.map +1 -0
  16. package/dist/features/execution/application/cancelDurableRunAsync.d.ts +8 -0
  17. package/dist/features/execution/application/cancelDurableRunAsync.d.ts.map +1 -0
  18. package/dist/features/execution/application/cancelDurableRunAsync.js +22 -0
  19. package/dist/features/execution/application/cancelDurableRunAsync.js.map +1 -0
  20. package/dist/features/execution/application/createDurableRunAsync.d.ts +12 -0
  21. package/dist/features/execution/application/createDurableRunAsync.d.ts.map +1 -0
  22. package/dist/features/execution/application/createDurableRunAsync.js +8 -0
  23. package/dist/features/execution/application/createDurableRunAsync.js.map +1 -0
  24. package/dist/features/execution/application/executeFlowAsync.d.ts +4 -0
  25. package/dist/features/execution/application/executeFlowAsync.d.ts.map +1 -0
  26. package/dist/features/execution/application/executeFlowAsync.js +211 -0
  27. package/dist/features/execution/application/executeFlowAsync.js.map +1 -0
  28. package/dist/features/execution/application/runDurableWorkerAsync.d.ts +52 -0
  29. package/dist/features/execution/application/runDurableWorkerAsync.d.ts.map +1 -0
  30. package/dist/features/execution/application/runDurableWorkerAsync.js +195 -0
  31. package/dist/features/execution/application/runDurableWorkerAsync.js.map +1 -0
  32. package/dist/features/execution/domain/assertFlowGraphSchemasSupported.d.ts +5 -0
  33. package/dist/features/execution/domain/assertFlowGraphSchemasSupported.d.ts.map +1 -0
  34. package/dist/features/execution/domain/assertFlowGraphSchemasSupported.js +11 -0
  35. package/dist/features/execution/domain/assertFlowGraphSchemasSupported.js.map +1 -0
  36. package/dist/features/execution/domain/calculateRetryDueAt.d.ts +4 -0
  37. package/dist/features/execution/domain/calculateRetryDueAt.d.ts.map +1 -0
  38. package/dist/features/execution/domain/calculateRetryDueAt.js +14 -0
  39. package/dist/features/execution/domain/calculateRetryDueAt.js.map +1 -0
  40. package/dist/features/execution/domain/compileFlowGraph.d.ts +5 -0
  41. package/dist/features/execution/domain/compileFlowGraph.d.ts.map +1 -0
  42. package/dist/features/execution/domain/compileFlowGraph.js +76 -0
  43. package/dist/features/execution/domain/compileFlowGraph.js.map +1 -0
  44. package/dist/features/execution/domain/createDefaultNodeRegistry.d.ts +4 -0
  45. package/dist/features/execution/domain/createDefaultNodeRegistry.d.ts.map +1 -0
  46. package/dist/features/execution/domain/createDefaultNodeRegistry.js +15 -0
  47. package/dist/features/execution/domain/createDefaultNodeRegistry.js.map +1 -0
  48. package/dist/features/execution/domain/createDurableRun.d.ts +7 -0
  49. package/dist/features/execution/domain/createDurableRun.d.ts.map +1 -0
  50. package/dist/features/execution/domain/createDurableRun.js +10 -0
  51. package/dist/features/execution/domain/createDurableRun.js.map +1 -0
  52. package/dist/features/execution/domain/createRetryWork.d.ts +4 -0
  53. package/dist/features/execution/domain/createRetryWork.d.ts.map +1 -0
  54. package/dist/features/execution/domain/createRetryWork.js +17 -0
  55. package/dist/features/execution/domain/createRetryWork.js.map +1 -0
  56. package/dist/features/execution/domain/isCronDueAt.d.ts +7 -0
  57. package/dist/features/execution/domain/isCronDueAt.d.ts.map +1 -0
  58. package/dist/features/execution/domain/isCronDueAt.js +114 -0
  59. package/dist/features/execution/domain/isCronDueAt.js.map +1 -0
  60. package/dist/features/execution/domain/validateFlowDataSchema.d.ts +6 -0
  61. package/dist/features/execution/domain/validateFlowDataSchema.d.ts.map +1 -0
  62. package/dist/features/execution/domain/validateFlowDataSchema.js +144 -0
  63. package/dist/features/execution/domain/validateFlowDataSchema.js.map +1 -0
  64. package/dist/features/execution/ports/FlowPersistencePort.d.ts +34 -0
  65. package/dist/features/execution/ports/FlowPersistencePort.d.ts.map +1 -0
  66. package/dist/features/execution/ports/FlowPersistencePort.js +2 -0
  67. package/dist/features/execution/ports/FlowPersistencePort.js.map +1 -0
  68. package/dist/features/mcp/McpToolError.d.ts +6 -0
  69. package/dist/features/mcp/McpToolError.d.ts.map +1 -0
  70. package/dist/features/mcp/McpToolError.js +10 -0
  71. package/dist/features/mcp/McpToolError.js.map +1 -0
  72. package/dist/features/mcp/createMcpRemoteTransport.d.ts +8 -0
  73. package/dist/features/mcp/createMcpRemoteTransport.d.ts.map +1 -0
  74. package/dist/features/mcp/createMcpRemoteTransport.js +23 -0
  75. package/dist/features/mcp/createMcpRemoteTransport.js.map +1 -0
  76. package/dist/features/mcp/createMcpToolRegistry.d.ts +10 -0
  77. package/dist/features/mcp/createMcpToolRegistry.d.ts.map +1 -0
  78. package/dist/features/mcp/createMcpToolRegistry.js +185 -0
  79. package/dist/features/mcp/createMcpToolRegistry.js.map +1 -0
  80. package/dist/features/mcp/loadMcpSdkAsync.d.ts +12 -0
  81. package/dist/features/mcp/loadMcpSdkAsync.d.ts.map +1 -0
  82. package/dist/features/mcp/loadMcpSdkAsync.js +20 -0
  83. package/dist/features/mcp/loadMcpSdkAsync.js.map +1 -0
  84. package/dist/features/mcp/prepareMcpProviders.d.ts +4 -0
  85. package/dist/features/mcp/prepareMcpProviders.d.ts.map +1 -0
  86. package/dist/features/mcp/prepareMcpProviders.js +97 -0
  87. package/dist/features/mcp/prepareMcpProviders.js.map +1 -0
  88. package/dist/features/mcp/validateMcpSchema.d.ts +6 -0
  89. package/dist/features/mcp/validateMcpSchema.d.ts.map +1 -0
  90. package/dist/features/mcp/validateMcpSchema.js +220 -0
  91. package/dist/features/mcp/validateMcpSchema.js.map +1 -0
  92. package/dist/features/security/FlowSecurityError.d.ts +6 -0
  93. package/dist/features/security/FlowSecurityError.d.ts.map +1 -0
  94. package/dist/features/security/FlowSecurityError.js +10 -0
  95. package/dist/features/security/FlowSecurityError.js.map +1 -0
  96. package/dist/features/security/application/createFlowSecurityPort.d.ts +12 -0
  97. package/dist/features/security/application/createFlowSecurityPort.d.ts.map +1 -0
  98. package/dist/features/security/application/createFlowSecurityPort.js +208 -0
  99. package/dist/features/security/application/createFlowSecurityPort.js.map +1 -0
  100. package/dist/features/security/domain/validateFlowSecurityTarget.d.ts +4 -0
  101. package/dist/features/security/domain/validateFlowSecurityTarget.d.ts.map +1 -0
  102. package/dist/features/security/domain/validateFlowSecurityTarget.js +57 -0
  103. package/dist/features/security/domain/validateFlowSecurityTarget.js.map +1 -0
  104. package/dist/index.d.ts +16 -0
  105. package/dist/index.d.ts.map +1 -0
  106. package/dist/index.js +12 -0
  107. package/dist/index.js.map +1 -0
  108. package/dist/mcp.d.ts +4 -0
  109. package/dist/mcp.d.ts.map +1 -0
  110. package/dist/mcp.js +3 -0
  111. package/dist/mcp.js.map +1 -0
  112. package/dist/social.d.ts +7 -0
  113. package/dist/social.d.ts.map +1 -0
  114. package/dist/social.js +7 -0
  115. package/dist/social.js.map +1 -0
  116. package/dist/types/durableExecution.d.ts +55 -0
  117. package/dist/types/durableExecution.d.ts.map +1 -0
  118. package/dist/types/durableExecution.js +2 -0
  119. package/dist/types/durableExecution.js.map +1 -0
  120. package/dist/types/execution.d.ts +33 -0
  121. package/dist/types/execution.d.ts.map +1 -0
  122. package/dist/types/execution.js +2 -0
  123. package/dist/types/execution.js.map +1 -0
  124. package/dist/types/mcp.d.ts +61 -0
  125. package/dist/types/mcp.d.ts.map +1 -0
  126. package/dist/types/mcp.js +2 -0
  127. package/dist/types/mcp.js.map +1 -0
  128. package/dist/types/security.d.ts +123 -0
  129. package/dist/types/security.d.ts.map +1 -0
  130. package/dist/types/security.js +2 -0
  131. package/dist/types/security.js.map +1 -0
  132. package/package.json +90 -4
  133. package/src/ai.ts +6 -0
  134. package/src/execution.ts +9 -0
  135. package/src/features/execution/adapters/FileFlowPersistenceAdapter.ts +333 -0
  136. package/src/features/execution/application/cancelDurableRunAsync.ts +28 -0
  137. package/src/features/execution/application/createDurableRunAsync.ts +14 -0
  138. package/src/features/execution/application/executeFlowAsync.ts +338 -0
  139. package/src/features/execution/application/runDurableWorkerAsync.ts +337 -0
  140. package/src/features/execution/domain/assertFlowGraphSchemasSupported.ts +17 -0
  141. package/src/features/execution/domain/calculateRetryDueAt.ts +21 -0
  142. package/src/features/execution/domain/compileFlowGraph.ts +97 -0
  143. package/src/features/execution/domain/createDefaultNodeRegistry.ts +26 -0
  144. package/src/features/execution/domain/createDurableRun.ts +14 -0
  145. package/src/features/execution/domain/createRetryWork.ts +22 -0
  146. package/src/features/execution/domain/isCronDueAt.ts +131 -0
  147. package/src/features/execution/domain/validateFlowDataSchema.ts +218 -0
  148. package/src/features/execution/ports/FlowPersistencePort.ts +38 -0
  149. package/src/features/mcp/McpToolError.ts +10 -0
  150. package/src/features/mcp/createMcpRemoteTransport.ts +34 -0
  151. package/src/features/mcp/createMcpToolRegistry.ts +324 -0
  152. package/src/features/mcp/loadMcpSdkAsync.ts +32 -0
  153. package/src/features/mcp/prepareMcpProviders.ts +135 -0
  154. package/src/features/mcp/validateMcpSchema.ts +269 -0
  155. package/src/features/security/FlowSecurityError.ts +17 -0
  156. package/src/features/security/application/createFlowSecurityPort.ts +339 -0
  157. package/src/features/security/domain/validateFlowSecurityTarget.ts +73 -0
  158. package/src/index.ts +49 -0
  159. package/src/mcp.ts +11 -0
  160. package/src/social.ts +6 -0
  161. package/src/types/durableExecution.ts +61 -0
  162. package/src/types/execution.ts +41 -0
  163. package/src/types/mcp.ts +70 -0
  164. package/src/types/security.ts +148 -0
@@ -0,0 +1,338 @@
1
+ import type { DataContractValue } from '@ankhorage/contracts/data';
2
+ import type {
3
+ FlowDataRef,
4
+ FlowEdge,
5
+ FlowError,
6
+ FlowNode,
7
+ FlowPortId,
8
+ FlowRunSnapshot,
9
+ FlowStepSnapshot,
10
+ } from '@ankhorage/contracts/flow';
11
+
12
+ import type {
13
+ FlowClock,
14
+ FlowCompiledGraph,
15
+ FlowExecutionResult,
16
+ FlowNodeRegistry,
17
+ FlowRunRequest,
18
+ } from '../../../types/execution.js';
19
+ import { assertFlowGraphSchemasSupported } from '../domain/assertFlowGraphSchemasSupported.js';
20
+ import { validateFlowDataSchema } from '../domain/validateFlowDataSchema.js';
21
+
22
+ /*** Execute a compiled graph in deterministic order within one process. */
23
+ export const executeFlowAsync = async (
24
+ graph: FlowCompiledGraph,
25
+ registry: FlowNodeRegistry,
26
+ request: FlowRunRequest,
27
+ clock: FlowClock = { now: () => new Date() },
28
+ ): Promise<FlowExecutionResult> => {
29
+ const trigger = graph.definition.triggers.find(({ id }) => id === request.triggerId);
30
+ if (trigger?.kind !== 'manual') throw new Error('FLOW_TRIGGER_NOT_MANUAL');
31
+ assertFlowGraphSchemasSupported(graph.definition.nodes, request.schemaRegistry);
32
+ const startedAt = clock.now().toISOString();
33
+ const state = await executeNodesAsync(graph, registry, request, clock);
34
+ return {
35
+ run: createRun(
36
+ graph,
37
+ request,
38
+ state.steps,
39
+ startedAt,
40
+ clock.now().toISOString(),
41
+ state.status ?? 'succeeded',
42
+ state.error,
43
+ ),
44
+ };
45
+ };
46
+
47
+ /*** Iterate compiled nodes in deterministic order until completion or cancellation. */
48
+ const executeNodesAsync = async (
49
+ graph: FlowCompiledGraph,
50
+ registry: FlowNodeRegistry,
51
+ request: FlowRunRequest,
52
+ clock: FlowClock,
53
+ ): Promise<ExecutionState> => {
54
+ const state: ExecutionState = {
55
+ activatedNodeIds: new Set(
56
+ graph.topologicalNodeIds.filter(
57
+ (nodeId) => !graph.definition.edges.some((edge) => edge.target.nodeId === nodeId),
58
+ ),
59
+ ),
60
+ inputs: {},
61
+ steps: [],
62
+ };
63
+ for (const nodeId of graph.topologicalNodeIds) {
64
+ if (request.signal?.aborted) return { ...state, status: 'cancelled' };
65
+ const node = graph.definition.nodes.find((candidate) => candidate.id === nodeId);
66
+ if (!node) throw new Error(`FLOW_GRAPH_UNKNOWN_NODE:${nodeId}`);
67
+ const result = await executeAvailableNodeAsync(node, state, registry, request, clock);
68
+ if (!result) continue;
69
+ state.steps.push(result.step);
70
+ if (result.step.status === 'cancelled') return { ...state, status: 'cancelled' };
71
+ propagateOutput(
72
+ graph.edgesBySourceNodeId[node.id] ?? [],
73
+ result.output,
74
+ result.step.status === 'failed' ? 'failure' : 'success',
75
+ state,
76
+ );
77
+ }
78
+ const failedStep = state.steps.find((step) => step.status === 'failed');
79
+ return { ...state, status: failedStep ? 'failed' : 'succeeded', error: failedStep?.error };
80
+ };
81
+
82
+ interface ExecutionState {
83
+ readonly activatedNodeIds: Set<string>;
84
+ readonly error?: FlowError;
85
+ readonly inputs: Record<string, Record<FlowPortId, FlowDataRef>>;
86
+ readonly status?: FlowRunSnapshot['status'];
87
+ readonly steps: FlowStepSnapshot[];
88
+ }
89
+
90
+ /*** Execute one node only when all of its required inputs were propagated. */
91
+ const executeAvailableNodeAsync = async (
92
+ node: FlowNode,
93
+ state: ExecutionState,
94
+ registry: FlowNodeRegistry,
95
+ request: FlowRunRequest,
96
+ clock: FlowClock,
97
+ ): Promise<
98
+ | { readonly output: Readonly<Record<FlowPortId, FlowDataRef>>; readonly step: FlowStepSnapshot }
99
+ | undefined
100
+ > => {
101
+ if (!state.activatedNodeIds.has(node.id)) return undefined;
102
+ const input = state.inputs[node.id] ?? request.input ?? {};
103
+ if (!hasRequiredInput(node, input)) return undefined;
104
+ return executeNodeAsync(node, input, registry, request.signal, clock, request.schemaRegistry);
105
+ };
106
+
107
+ /*** Determine whether every required input port has a propagated value. */
108
+ const hasRequiredInput = (
109
+ node: FlowNode,
110
+ input: Readonly<Record<FlowPortId, FlowDataRef>>,
111
+ ): boolean =>
112
+ node.ports
113
+ .filter((port) => port.direction === 'input' && port.required)
114
+ .every((port) => input[port.id] !== undefined);
115
+
116
+ /*** Execute one node and convert failures to redaction-safe contract errors. */
117
+ const executeNodeAsync = async (
118
+ node: FlowNode,
119
+ input: Readonly<Record<FlowPortId, FlowDataRef>>,
120
+ registry: FlowNodeRegistry,
121
+ signal: AbortSignal | undefined,
122
+ clock: FlowClock,
123
+ schemas: FlowRunRequest['schemaRegistry'],
124
+ ): Promise<{
125
+ readonly output: Readonly<Record<FlowPortId, FlowDataRef>>;
126
+ readonly step: FlowStepSnapshot;
127
+ }> => {
128
+ const startedAt = clock.now().toISOString();
129
+ try {
130
+ assertPortSchemas(node, input, 'input', schemas);
131
+ const operationId = `${node.capability.providerId}:${node.capability.capabilityId}`;
132
+ const operation = Object.entries(registry).find(([id]) => id === operationId)?.[1];
133
+ if (!operation) throw new Error(`FLOW_OPERATION_UNREGISTERED:${operationId}`);
134
+ const output =
135
+ (
136
+ await operation.executeAsync({
137
+ config: node.config,
138
+ input,
139
+ node,
140
+ signal: signal ?? new AbortController().signal,
141
+ })
142
+ ).output ?? {};
143
+ if (signal?.aborted)
144
+ return { output: {}, step: createCancelledStep(node, input, startedAt, clock) };
145
+ assertOutputPorts(node, output);
146
+ assertPortSchemas(node, output, 'output', schemas);
147
+ return { output, step: createSucceededStep(node, input, output, startedAt, clock) };
148
+ } catch (reason) {
149
+ const error = asFlowError(reason);
150
+ return {
151
+ output: {},
152
+ step: {
153
+ id: `${node.id}:1`,
154
+ nodeId: node.id,
155
+ status: 'failed',
156
+ attempt: 1,
157
+ startedAt,
158
+ completedAt: clock.now().toISOString(),
159
+ input,
160
+ error,
161
+ },
162
+ };
163
+ }
164
+ };
165
+
166
+ /*** Create an immutable successful step snapshot. */
167
+ const createSucceededStep = (
168
+ node: FlowNode,
169
+ input: Readonly<Record<FlowPortId, FlowDataRef>>,
170
+ output: Readonly<Record<FlowPortId, FlowDataRef>>,
171
+ startedAt: string,
172
+ clock: FlowClock,
173
+ ): FlowStepSnapshot => ({
174
+ id: `${node.id}:1`,
175
+ nodeId: node.id,
176
+ status: 'succeeded',
177
+ attempt: 1,
178
+ startedAt,
179
+ completedAt: clock.now().toISOString(),
180
+ input,
181
+ output,
182
+ });
183
+
184
+ /*** Create a terminal snapshot when cancellation occurs during asynchronous node execution. */
185
+ const createCancelledStep = (
186
+ node: FlowNode,
187
+ input: Readonly<Record<FlowPortId, FlowDataRef>>,
188
+ startedAt: string,
189
+ clock: FlowClock,
190
+ ): FlowStepSnapshot => ({
191
+ id: `${node.id}:1`,
192
+ nodeId: node.id,
193
+ status: 'cancelled',
194
+ attempt: 1,
195
+ startedAt,
196
+ completedAt: clock.now().toISOString(),
197
+ input,
198
+ });
199
+
200
+ /*** Reject operation outputs that do not correspond to declared output ports. */
201
+ const assertOutputPorts = (
202
+ node: FlowNode,
203
+ output: Readonly<Record<FlowPortId, FlowDataRef>>,
204
+ ): void => {
205
+ const outputIds = new Set(
206
+ node.ports.filter((port) => port.direction === 'output').map(({ id }) => id),
207
+ );
208
+ if (Object.keys(output).some((portId) => !outputIds.has(portId)))
209
+ throw new Error(`FLOW_OUTPUT_UNDECLARED_PORT:${node.id}`);
210
+ const missingRequired = node.ports.some(
211
+ (port) => port.direction === 'output' && port.required && output[port.id] === undefined,
212
+ );
213
+ if (missingRequired) throw new Error(`FLOW_OUTPUT_REQUIRED_PORT_MISSING:${node.id}`);
214
+ };
215
+
216
+ /*** Validate inline port values against their declared portable schemas. */
217
+ const assertPortSchemas = (
218
+ node: FlowNode,
219
+ values: Readonly<Record<FlowPortId, FlowDataRef>>,
220
+ direction: 'input' | 'output',
221
+ schemas: FlowRunRequest['schemaRegistry'],
222
+ ): void => {
223
+ for (const port of node.ports.filter((candidate) => candidate.direction === direction)) {
224
+ const value = values[port.id];
225
+ const schema =
226
+ port.data?.schema ?? (port.data?.schemaRef ? { ref: port.data.schemaRef } : undefined);
227
+ if (value?.kind === 'inline' && schema && !validateFlowDataSchema(value.value, schema, schemas))
228
+ throw new Error(`FLOW_PORT_SCHEMA_MISMATCH:${node.id}:${port.id}`);
229
+ }
230
+ };
231
+
232
+ /*** Propagate outputs only over edges whose declared condition matches the output value. */
233
+ const propagateOutput = (
234
+ edges: readonly FlowEdge[],
235
+ output: Readonly<Record<FlowPortId, FlowDataRef>>,
236
+ outcome: 'failure' | 'success',
237
+ state: ExecutionState,
238
+ ): void => {
239
+ for (const edge of edges) {
240
+ const value = output[edge.source.portId];
241
+ if (!matchesEdge(edge, value, outcome)) continue;
242
+ state.activatedNodeIds.add(edge.target.nodeId);
243
+ if (value)
244
+ state.inputs[edge.target.nodeId] = {
245
+ ...state.inputs[edge.target.nodeId],
246
+ [edge.target.portId]: value,
247
+ };
248
+ }
249
+ };
250
+
251
+ /*** Evaluate an edge condition without executing arbitrary expressions. */
252
+ const matchesEdge = (
253
+ edge: FlowEdge,
254
+ value: FlowDataRef | undefined,
255
+ outcome: 'failure' | 'success',
256
+ ): boolean => {
257
+ const condition = edge.condition ?? { kind: 'always' as const };
258
+ if (condition.kind === 'always') return outcome === 'success';
259
+ if (condition.kind === 'outcome') return condition.outcome === outcome;
260
+ if (value?.kind !== 'inline') return false;
261
+ const selected = resolvePointer(value.value, condition.path);
262
+ return selected !== undefined && structurallyEqual(selected, condition.equals);
263
+ };
264
+
265
+ /*** Resolve an RFC 6901 pointer against serializable inline data. */
266
+ const resolvePointer = (
267
+ value: DataContractValue,
268
+ pointer: string,
269
+ ): DataContractValue | undefined => {
270
+ if (pointer === '') return value;
271
+ if (!pointer.startsWith('/')) return undefined;
272
+ return pointer
273
+ .slice(1)
274
+ .split('/')
275
+ .reduce<DataContractValue | undefined>((current, segment) => {
276
+ if (current === undefined || current === null || typeof current !== 'object')
277
+ return undefined;
278
+ const key = segment.replaceAll('~1', '/').replaceAll('~0', '~');
279
+ if (isDataArray(current)) return current.find((_item, index) => index === Number(key));
280
+ return isDataRecord(current)
281
+ ? Object.entries(current).find(([candidateKey]) => candidateKey === key)?.[1]
282
+ : undefined;
283
+ }, value);
284
+ };
285
+
286
+ /*** Compare JSON-compatible values with object-key-order independence. */
287
+ const structurallyEqual = (left: DataContractValue, right: DataContractValue): boolean =>
288
+ JSON.stringify(normalizeValue(left)) === JSON.stringify(normalizeValue(right));
289
+
290
+ /*** Canonicalize object member ordering for structural equality. */
291
+ const normalizeValue = (value: DataContractValue): DataContractValue => {
292
+ if (Array.isArray(value)) return value.map(normalizeValue);
293
+ if (value !== null && typeof value === 'object')
294
+ return Object.fromEntries(
295
+ Object.entries(value)
296
+ .sort(([left], [right]) => left.localeCompare(right))
297
+ .map(([key, item]) => [key, normalizeValue(item)]),
298
+ );
299
+ return value;
300
+ };
301
+
302
+ /*** Narrow a serializable non-array object to an indexable data record. */
303
+ const isDataRecord = (value: object): value is Readonly<Record<string, DataContractValue>> =>
304
+ !Array.isArray(value);
305
+
306
+ /*** Narrow a serializable array without losing its element type. */
307
+ const isDataArray = (value: object): value is readonly DataContractValue[] => Array.isArray(value);
308
+
309
+ /*** Translate host failures to structured contract errors without exposing arbitrary objects. */
310
+ const asFlowError = (reason: unknown): FlowError => ({
311
+ code:
312
+ reason instanceof Error && /^FLOW_[A-Z0-9_]+(?::|$)/.test(reason.message)
313
+ ? (reason.message.split(':')[0] ?? 'FLOW_OPERATION_FAILED')
314
+ : 'FLOW_OPERATION_FAILED',
315
+ message: 'The operation failed.',
316
+ retryable: false,
317
+ });
318
+
319
+ /*** Build the immutable run result emitted by the single-process executor. */
320
+ const createRun = (
321
+ graph: FlowCompiledGraph,
322
+ request: FlowRunRequest,
323
+ steps: readonly FlowStepSnapshot[],
324
+ startedAt: string,
325
+ completedAt: string,
326
+ status: FlowRunSnapshot['status'],
327
+ error?: FlowError,
328
+ ): FlowRunSnapshot => ({
329
+ id: request.runId,
330
+ flow: { id: graph.definition.id, version: graph.definition.version },
331
+ status,
332
+ trigger: { triggerId: request.triggerId, kind: 'manual' },
333
+ createdAt: startedAt,
334
+ startedAt,
335
+ completedAt,
336
+ steps,
337
+ error,
338
+ });
@@ -0,0 +1,337 @@
1
+ import type {
2
+ FlowDurableRun,
3
+ FlowDurableWork,
4
+ FlowRetryPolicy,
5
+ FlowRunCheckpoint,
6
+ } from '../../../types/durableExecution.js';
7
+ import type { FlowClock } from '../../../types/execution.js';
8
+ import type { FlowSecurityPort } from '../../../types/security.js';
9
+ import { FlowSecurityError } from '../../security/FlowSecurityError.js';
10
+ import { createRetryWork } from '../domain/createRetryWork.js';
11
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
12
+
13
+ /** Claim bounded durable work and execute it with fenced recovery, retry, and approval handling. */
14
+ export const runDurableWorkerAsync = async (
15
+ input: {
16
+ readonly approvalPollIntervalMs: number;
17
+ readonly concurrency: number;
18
+ readonly leaseDurationMs: number;
19
+ readonly limit: number;
20
+ readonly retry: FlowRetryPolicy;
21
+ readonly workerId: string;
22
+ },
23
+ dependencies: {
24
+ readonly clock: FlowClock;
25
+ readonly effects: FlowDurableEffectPort;
26
+ readonly persistence: FlowPersistencePort;
27
+ readonly security?: FlowSecurityPort;
28
+ },
29
+ ): Promise<FlowWorkerResult> => {
30
+ assertWorkerInput(input);
31
+ const now = dependencies.clock.now();
32
+ const recoveredWorkIds = await dependencies.persistence.recoverExpiredClaimsAsync(now);
33
+ const claimed = await dependencies.persistence.claimDueWorkAsync({
34
+ leaseDurationMs: input.leaseDurationMs,
35
+ limit: Math.min(input.limit, input.concurrency),
36
+ now,
37
+ workerId: input.workerId,
38
+ });
39
+ const outcomes = await Promise.all(
40
+ claimed.map((work) => processWorkAsync(work, input, dependencies)),
41
+ );
42
+ return { claimedWorkIds: claimed.map((work) => work.id), outcomes, recoveredWorkIds };
43
+ };
44
+
45
+ /** Executes one effect through the durable step and security boundaries. */
46
+ const processWorkAsync = async (
47
+ work: FlowDurableWork,
48
+ input: Parameters<typeof runDurableWorkerAsync>[0],
49
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
50
+ ): Promise<FlowWorkerOutcome> => {
51
+ const { lease } = work;
52
+ if (lease === undefined) return { status: 'lost_lease', workId: work.id };
53
+ const run = await dependencies.persistence.readRunAsync(work.runId);
54
+ if (run === undefined || run.status === 'cancelled') {
55
+ await completeAsync(work, input.workerId, dependencies);
56
+ return { status: 'cancelled', workId: work.id };
57
+ }
58
+ if (isCompleted(run.checkpoint, work.stepId)) {
59
+ await completeAsync(work, input.workerId, dependencies);
60
+ return { status: 'already_completed', workId: work.id };
61
+ }
62
+ try {
63
+ const outbox = await prepareOutboxAsync(work, dependencies);
64
+ if (outbox.status === 'delivered') {
65
+ const transitioned = await transitionAsync(
66
+ run,
67
+ 'succeeded',
68
+ completedCheckpoint(run.checkpoint, work.stepId),
69
+ dependencies,
70
+ );
71
+ const completed = await completeAsync(work, input.workerId, dependencies);
72
+ return {
73
+ status: transitioned && completed ? 'already_completed' : 'lost_lease',
74
+ workId: work.id,
75
+ };
76
+ }
77
+ const effectStatus = await executeEffectAsync(work, run, dependencies);
78
+ await dependencies.persistence.markOutboxDeliveredAsync(work.id);
79
+ const transitioned = await transitionAsync(
80
+ run,
81
+ 'succeeded',
82
+ completedCheckpoint(run.checkpoint, work.stepId),
83
+ dependencies,
84
+ );
85
+ if (!transitioned || !(await completeAsync(work, input.workerId, dependencies)))
86
+ return { status: 'lost_lease', workId: work.id };
87
+ return { status: effectStatus, workId: work.id };
88
+ } catch (error) {
89
+ if (isApprovalPending(error)) return waitForApprovalAsync(work, run, input, dependencies);
90
+ return retryOrFailAsync(work, run, input, dependencies, error);
91
+ }
92
+ };
93
+
94
+ /** Create an idempotency-keyed outbox record before delivery, or resume its pending record after a crash. */
95
+ const prepareOutboxAsync = async (
96
+ work: FlowDurableWork,
97
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
98
+ ) => {
99
+ const existing = await dependencies.persistence.readOutboxAsync(work.id);
100
+ if (existing !== undefined) return existing;
101
+ const now = dependencies.clock.now().toISOString();
102
+ await dependencies.persistence.createOutboxAsync({
103
+ createdAt: now,
104
+ id: work.id,
105
+ idempotencyKey: work.idempotencyKey ?? work.id,
106
+ runId: work.runId,
107
+ status: 'pending',
108
+ workId: work.id,
109
+ });
110
+ const created = await dependencies.persistence.readOutboxAsync(work.id);
111
+ if (created === undefined) throw new Error('FLOW_OUTBOX_CREATE_FAILED');
112
+ return created;
113
+ };
114
+
115
+ /** Runs internal work directly, while protected provider work requires reconciliation and a canonical permit. */
116
+ const executeEffectAsync = async (
117
+ work: FlowDurableWork,
118
+ run: FlowDurableRun,
119
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
120
+ ): Promise<'succeeded'> => {
121
+ const execute = () =>
122
+ dependencies.effects.executeAsync({
123
+ idempotencyKey: work.idempotencyKey ?? work.id,
124
+ run,
125
+ work,
126
+ });
127
+ if (work.effectKind === 'internal') {
128
+ if (work.securityOperation !== undefined)
129
+ throw new Error('FLOW_INTERNAL_EFFECT_SECURITY_OPERATION');
130
+ await execute();
131
+ return 'succeeded';
132
+ }
133
+ if (work.securityOperation === undefined)
134
+ throw new Error('FLOW_PROTECTED_EFFECT_SECURITY_OPERATION');
135
+ if (dependencies.security === undefined) throw new Error('FLOW_SECURITY_PORT_REQUIRED');
136
+ if (dependencies.effects.reconcileAsync === undefined)
137
+ throw new Error('FLOW_PROTECTED_EFFECT_RECONCILIATION_REQUIRED');
138
+ if (
139
+ (await dependencies.effects.reconcileAsync({
140
+ idempotencyKey: work.idempotencyKey ?? work.id,
141
+ run,
142
+ work,
143
+ })) === 'delivered'
144
+ )
145
+ return 'succeeded';
146
+ const permit = await dependencies.security.authorizeAsync(work.securityOperation);
147
+ await permit.executeAsync(async () => execute());
148
+ return 'succeeded';
149
+ };
150
+
151
+ /** Persist a waiting approval and defer its at-least-once delivery without consuming the lease. */
152
+ const waitForApprovalAsync = async (
153
+ work: FlowDurableWork,
154
+ run: FlowDurableRun,
155
+ input: Parameters<typeof runDurableWorkerAsync>[0],
156
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
157
+ ): Promise<FlowWorkerOutcome> => {
158
+ const transitioned = await transitionAsync(run, 'waiting_approval', run.checkpoint, dependencies);
159
+ const released = await dependencies.persistence.releaseWorkAsync({
160
+ dueAt: new Date(
161
+ dependencies.clock.now().getTime() + input.approvalPollIntervalMs,
162
+ ).toISOString(),
163
+ id: work.id,
164
+ leaseFencingToken: claimFencingToken(work),
165
+ workerId: input.workerId,
166
+ });
167
+ return { status: transitioned && released ? 'waiting_approval' : 'lost_lease', workId: work.id };
168
+ };
169
+
170
+ /** Persist a bounded retry or terminal failure before releasing the claimed delivery. */
171
+ const retryOrFailAsync = async (
172
+ work: FlowDurableWork,
173
+ run: FlowDurableRun,
174
+ input: Parameters<typeof runDurableWorkerAsync>[0],
175
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
176
+ error: unknown,
177
+ ): Promise<FlowWorkerOutcome> => {
178
+ if (work.attempt >= input.retry.maxAttempts) {
179
+ await dependencies.effects.compensateAsync?.({ run, work });
180
+ const transitioned = await transitionAsync(
181
+ run,
182
+ 'failed',
183
+ failedCheckpoint(run.checkpoint, work.stepId),
184
+ dependencies,
185
+ );
186
+ const completed = await completeAsync(work, input.workerId, dependencies);
187
+ return {
188
+ error: errorMessage(error),
189
+ status: transitioned && completed ? 'failed' : 'lost_lease',
190
+ workId: work.id,
191
+ };
192
+ }
193
+ await dependencies.persistence.enqueueWorkAsync(
194
+ createRetryWork(work, dependencies.clock.now(), input.retry),
195
+ );
196
+ const completed = await completeAsync(work, input.workerId, dependencies);
197
+ return {
198
+ error: errorMessage(error),
199
+ status: completed ? 'retry_scheduled' : 'lost_lease',
200
+ workId: work.id,
201
+ };
202
+ };
203
+
204
+ /** Compare-and-swap a run state while preserving its immutable flow revision. */
205
+ const transitionAsync = (
206
+ run: FlowDurableRun,
207
+ status: FlowDurableRun['status'],
208
+ checkpoint: FlowRunCheckpoint | undefined,
209
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
210
+ ): Promise<boolean> =>
211
+ dependencies.persistence.transitionRunAsync({
212
+ expectedRevision: run.revision,
213
+ run: {
214
+ ...run,
215
+ ...(checkpoint === undefined ? {} : { checkpoint }),
216
+ revision: run.revision + 1,
217
+ status,
218
+ updatedAt: dependencies.clock.now().toISOString(),
219
+ },
220
+ });
221
+
222
+ /** Complete a work lease only when its owner and fencing token still match. */
223
+ const completeAsync = (
224
+ work: FlowDurableWork,
225
+ workerId: string,
226
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
227
+ ): Promise<boolean> =>
228
+ dependencies.persistence.completeWorkAsync({
229
+ id: work.id,
230
+ leaseFencingToken: claimFencingToken(work),
231
+ now: dependencies.clock.now(),
232
+ workerId,
233
+ });
234
+
235
+ /** Read the lease token only from a claimed work item. */
236
+ const claimFencingToken = (work: FlowDurableWork): number => {
237
+ if (work.lease === undefined) throw new Error('FLOW_WORK_LEASE_MISSING');
238
+ return work.lease.fencingToken;
239
+ };
240
+
241
+ /** Add a successful step exactly once to an immutable checkpoint. */
242
+ const completedCheckpoint = (
243
+ checkpoint: FlowRunCheckpoint | undefined,
244
+ stepId: string | undefined,
245
+ ): FlowRunCheckpoint => ({
246
+ completedStepIds: unique([
247
+ ...(checkpoint?.completedStepIds ?? []),
248
+ ...(stepId === undefined ? [] : [stepId]),
249
+ ]),
250
+ ...(checkpoint?.data === undefined ? {} : { data: checkpoint.data }),
251
+ failedStepIds: checkpoint?.failedStepIds ?? [],
252
+ });
253
+
254
+ /** Add a terminally failed step to an immutable checkpoint. */
255
+ const failedCheckpoint = (
256
+ checkpoint: FlowRunCheckpoint | undefined,
257
+ stepId: string | undefined,
258
+ ): FlowRunCheckpoint => ({
259
+ completedStepIds: checkpoint?.completedStepIds ?? [],
260
+ ...(checkpoint?.data === undefined ? {} : { data: checkpoint.data }),
261
+ failedStepIds: unique([
262
+ ...(checkpoint?.failedStepIds ?? []),
263
+ ...(stepId === undefined ? [] : [stepId]),
264
+ ]),
265
+ });
266
+
267
+ /** Check whether a completed checkpoint makes a duplicate delivery a no-op. */
268
+ const isCompleted = (
269
+ checkpoint: FlowRunCheckpoint | undefined,
270
+ stepId: string | undefined,
271
+ ): boolean => stepId !== undefined && checkpoint?.completedStepIds.includes(stepId) === true;
272
+
273
+ /** Validate finite worker bounds before touching persistence. */
274
+ const assertWorkerInput = (input: Parameters<typeof runDurableWorkerAsync>[0]): void => {
275
+ for (const value of [
276
+ input.approvalPollIntervalMs,
277
+ input.concurrency,
278
+ input.leaseDurationMs,
279
+ input.limit,
280
+ input.retry.maxAttempts,
281
+ ])
282
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error('FLOW_WORKER_INPUT_INVALID');
283
+ };
284
+
285
+ /** Identify the canonical pending-approval error without interpreting untrusted error shapes. */
286
+ const isApprovalPending = (error: unknown): boolean =>
287
+ error instanceof FlowSecurityError && error.code === 'approval_pending';
288
+
289
+ /** Preserve a concise, provider-neutral failure signal in the worker result. */
290
+ const errorMessage = (error: unknown): string =>
291
+ error instanceof Error ? error.message : 'FLOW_EFFECT_FAILED';
292
+
293
+ /** Keep persisted checkpoint arrays deterministic. */
294
+ const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
295
+
296
+ /** Application-owned effect boundary; adapters must apply the supplied idempotency key at their side-effect boundary. */
297
+ export interface FlowDurableEffectPort {
298
+ readonly compensateAsync?: (input: {
299
+ readonly run: FlowDurableRun;
300
+ readonly work: FlowDurableWork;
301
+ }) => Promise<void>;
302
+ readonly executeAsync: (input: {
303
+ readonly idempotencyKey: string;
304
+ readonly run: FlowDurableRun;
305
+ readonly work: FlowDurableWork;
306
+ }) => Promise<void>;
307
+ /**
308
+ * Query the provider's idempotency boundary before a protected replay. A provider must return
309
+ * `delivered` only when the stable key proves that the original effect completed.
310
+ */
311
+ readonly reconcileAsync?: (input: {
312
+ readonly idempotencyKey: string;
313
+ readonly run: FlowDurableRun;
314
+ readonly work: FlowDurableWork;
315
+ }) => Promise<'delivered' | 'unknown'>;
316
+ }
317
+
318
+ /** Observable outcome for every delivery claimed by one bounded worker tick. */
319
+ export interface FlowWorkerOutcome {
320
+ readonly error?: string;
321
+ readonly status:
322
+ | 'already_completed'
323
+ | 'cancelled'
324
+ | 'failed'
325
+ | 'lost_lease'
326
+ | 'retry_scheduled'
327
+ | 'succeeded'
328
+ | 'waiting_approval';
329
+ readonly workId: string;
330
+ }
331
+
332
+ /** Summary of one worker tick; delivery remains at-least-once until fenced completion. */
333
+ export interface FlowWorkerResult {
334
+ readonly claimedWorkIds: readonly string[];
335
+ readonly outcomes: readonly FlowWorkerOutcome[];
336
+ readonly recoveredWorkIds: readonly string[];
337
+ }
@@ -0,0 +1,17 @@
1
+ import type { DataSchemaRegistry } from '@ankhorage/contracts/data';
2
+ import type { FlowNode } from '@ankhorage/contracts/flow';
3
+
4
+ import { assertFlowDataSchemaSupported } from './validateFlowDataSchema.js';
5
+
6
+ /*** Validate every declared graph port schema before any operation can perform a side effect. */
7
+ export const assertFlowGraphSchemasSupported = (
8
+ nodes: readonly FlowNode[],
9
+ schemas: DataSchemaRegistry | undefined,
10
+ ): void => {
11
+ for (const node of nodes)
12
+ for (const port of node.ports) {
13
+ const schema =
14
+ port.data?.schema ?? (port.data?.schemaRef ? { ref: port.data.schemaRef } : undefined);
15
+ if (schema) assertFlowDataSchemaSupported(schema, schemas);
16
+ }
17
+ };