@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,9 @@
1
+ /*** Node-specific durable-execution adapters. */
2
+ export { FileFlowPersistenceAdapter } from './features/execution/adapters/FileFlowPersistenceAdapter.js';
3
+ export { cancelDurableRunAsync } from './features/execution/application/cancelDurableRunAsync.js';
4
+ export {
5
+ type FlowDurableEffectPort,
6
+ type FlowWorkerOutcome,
7
+ type FlowWorkerResult,
8
+ runDurableWorkerAsync,
9
+ } from './features/execution/application/runDurableWorkerAsync.js';
@@ -0,0 +1,333 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+
5
+ import type {
6
+ FlowDurableRun,
7
+ FlowDurableWork,
8
+ FlowOutboxRecord,
9
+ } from '../../../types/durableExecution.js';
10
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
11
+
12
+ /*** Create a self-hostable, single-process JSON persistence adapter for durable Flow state. */
13
+ export const FileFlowPersistenceAdapter = (path: string): FlowPersistencePort => {
14
+ const persistencePath = resolve(path);
15
+ const serializedCall = getSerializedCall(persistencePath);
16
+ return {
17
+ cancelRunWorkAsync: (runId) =>
18
+ serializedCall.runAsync(() => cancelRunWorkAsync(persistencePath, runId)),
19
+ claimDueWorkAsync: (input) =>
20
+ serializedCall.runAsync(() => claimDueWorkAsync(persistencePath, input)),
21
+ completeWorkAsync: (input) =>
22
+ serializedCall.runAsync(() => completeWorkAsync(persistencePath, input)),
23
+ createOutboxAsync: (record) =>
24
+ serializedCall.runAsync(() => createOutboxAsync(persistencePath, record)),
25
+ createRunAsync: (run) => serializedCall.runAsync(() => createRunAsync(persistencePath, run)),
26
+ enqueueWorkAsync: (work) =>
27
+ serializedCall.runAsync(() => enqueueWorkAsync(persistencePath, work)),
28
+ markOutboxDeliveredAsync: (id) =>
29
+ serializedCall.runAsync(() => markOutboxDeliveredAsync(persistencePath, id)),
30
+ readOutboxAsync: (id) => serializedCall.runAsync(() => readOutboxAsync(persistencePath, id)),
31
+ readRunAsync: (id) => serializedCall.runAsync(() => readRunAsync(persistencePath, id)),
32
+ recoverExpiredClaimsAsync: (now) =>
33
+ serializedCall.runAsync(() => recoverExpiredClaimsAsync(persistencePath, now)),
34
+ releaseWorkAsync: (input) =>
35
+ serializedCall.runAsync(() => releaseWorkAsync(persistencePath, input)),
36
+ transitionRunAsync: (input) =>
37
+ serializedCall.runAsync(() => transitionRunAsync(persistencePath, input)),
38
+ };
39
+ };
40
+
41
+ /** Cancel every nonterminal delivery for a run after its durable cancellation transition. */
42
+ const cancelRunWorkAsync = async (path: string, runId: string): Promise<readonly string[]> =>
43
+ updateStoreAsync(path, (store) => {
44
+ const cancelled = store.work
45
+ .filter(
46
+ (work) => work.runId === runId && (work.status === 'ready' || work.status === 'claimed'),
47
+ )
48
+ .map((work) => ({ ...work, lease: undefined, status: 'cancelled' as const }));
49
+ return {
50
+ result: cancelled.map((work) => work.id),
51
+ store: { ...store, work: replaceWork(store.work, cancelled) },
52
+ };
53
+ });
54
+
55
+ interface FileFlowStore {
56
+ readonly nextLeaseFencingToken: number;
57
+ readonly outbox: readonly FlowOutboxRecord[];
58
+ readonly runs: readonly FlowDurableRun[];
59
+ readonly work: readonly FlowDurableWork[];
60
+ }
61
+
62
+ /** Persist a stable outbox identity before any external effect is attempted. */
63
+ const createOutboxAsync = async (path: string, record: FlowOutboxRecord): Promise<void> =>
64
+ updateStoreAsync(path, (store) => {
65
+ const existing = store.outbox.find((candidate) => candidate.id === record.id);
66
+ if (existing !== undefined && existing.idempotencyKey !== record.idempotencyKey)
67
+ throw new Error(`FLOW_OUTBOX_CONFLICT:${record.id}`);
68
+ return {
69
+ result: undefined,
70
+ store: existing === undefined ? { ...store, outbox: [...store.outbox, copy(record)] } : store,
71
+ };
72
+ });
73
+
74
+ interface SerializedCall {
75
+ readonly runAsync: <Result>(operation: () => Promise<Result>) => Promise<Result>;
76
+ }
77
+
78
+ const serializedCallsByPath = new Map<string, SerializedCall>();
79
+
80
+ /*** Share serialization for every adapter instance targeting one normalized persistence path. */
81
+ const getSerializedCall = (path: string): SerializedCall => {
82
+ const existing = serializedCallsByPath.get(path);
83
+ if (existing !== undefined) return existing;
84
+ const serializedCall = createSerializedCall();
85
+ serializedCallsByPath.set(path, serializedCall);
86
+ return serializedCall;
87
+ };
88
+
89
+ /*** Serialize one file path so concurrent callbacks cannot claim or overwrite the same state. */
90
+ const createSerializedCall = (): SerializedCall => {
91
+ let previous = Promise.resolve();
92
+ return {
93
+ runAsync: async (operation) => {
94
+ const current = previous.then(operation, operation);
95
+ previous = current.then(
96
+ () => undefined,
97
+ () => undefined,
98
+ );
99
+ return current;
100
+ },
101
+ };
102
+ };
103
+
104
+ /*** Claim due work in stable order and record a lease before it is returned to a worker. */
105
+ const claimDueWorkAsync = async (
106
+ path: string,
107
+ input: Parameters<FlowPersistencePort['claimDueWorkAsync']>[0],
108
+ ): Promise<readonly FlowDurableWork[]> =>
109
+ updateStoreAsync(path, (store) => {
110
+ const claimed = store.work
111
+ .filter((work) => work.status === 'ready' && work.dueAt <= input.now.toISOString())
112
+ .sort(
113
+ (left, right) => left.dueAt.localeCompare(right.dueAt) || left.id.localeCompare(right.id),
114
+ )
115
+ .slice(0, input.limit)
116
+ .map((work, index) => claimWork(work, input, store.nextLeaseFencingToken + index));
117
+ return {
118
+ result: claimed,
119
+ store: {
120
+ ...store,
121
+ nextLeaseFencingToken: store.nextLeaseFencingToken + claimed.length,
122
+ work: replaceWork(store.work, claimed),
123
+ },
124
+ };
125
+ });
126
+
127
+ /*** Complete only the unexpired fenced lease returned by the successful claim. */
128
+ const completeWorkAsync = async (
129
+ path: string,
130
+ { id, leaseFencingToken, now, workerId }: Parameters<FlowPersistencePort['completeWorkAsync']>[0],
131
+ ): Promise<boolean> =>
132
+ updateStoreAsync(path, (store) => {
133
+ const work = store.work.find((candidate) => candidate.id === id);
134
+ if (
135
+ work?.status !== 'claimed' ||
136
+ work.lease?.workerId !== workerId ||
137
+ work.lease.fencingToken !== leaseFencingToken ||
138
+ work.lease.expiresAt <= now.toISOString()
139
+ )
140
+ return { result: false, store };
141
+ return {
142
+ result: true,
143
+ store: { ...store, work: replaceWork(store.work, [{ ...work, status: 'completed' }]) },
144
+ };
145
+ });
146
+
147
+ /*** Persist a new run once, rejecting a duplicate durable identity. */
148
+ const createRunAsync = async (path: string, run: FlowDurableRun): Promise<void> =>
149
+ updateStoreAsync(path, (store) => {
150
+ if (store.runs.some((candidate) => candidate.id === run.id))
151
+ throw new Error(`FLOW_RUN_EXISTS:${run.id}`);
152
+ return { result: undefined, store: { ...store, runs: [...store.runs, copy(run)] } };
153
+ });
154
+
155
+ /*** Persist one immutable work identity once, rejecting duplicate delivery records. */
156
+ const enqueueWorkAsync = async (path: string, work: FlowDurableWork): Promise<void> =>
157
+ updateStoreAsync(path, (store) => {
158
+ if (store.work.some((candidate) => candidate.id === work.id))
159
+ throw new Error(`FLOW_WORK_EXISTS:${work.id}`);
160
+ return { result: undefined, store: { ...store, work: [...store.work, copy(work)] } };
161
+ });
162
+
163
+ /** Mark an effect delivery complete; duplicate marks are harmless and do not rerun the effect. */
164
+ const markOutboxDeliveredAsync = async (path: string, id: string): Promise<boolean> =>
165
+ updateStoreAsync(path, (store) => {
166
+ const record = store.outbox.find((candidate) => candidate.id === id);
167
+ if (record === undefined) return { result: false, store };
168
+ return {
169
+ result: true,
170
+ store: {
171
+ ...store,
172
+ outbox: replaceOutbox(store.outbox, [{ ...record, status: 'delivered' }]),
173
+ },
174
+ };
175
+ });
176
+
177
+ /** Read a copied outbox snapshot without exposing adapter-owned mutable state. */
178
+ const readOutboxAsync = async (path: string, id: string): Promise<FlowOutboxRecord | undefined> =>
179
+ copy((await readStoreAsync(path)).outbox.find((record) => record.id === id));
180
+
181
+ /*** Read a copied run snapshot so callers cannot mutate persisted state in-place. */
182
+ const readRunAsync = async (path: string, id: string): Promise<FlowDurableRun | undefined> =>
183
+ copy((await readStoreAsync(path)).runs.find((run) => run.id === id));
184
+
185
+ /*** Make expired claims available for recovery after a worker crash or stop. */
186
+ const recoverExpiredClaimsAsync = async (path: string, now: Date): Promise<readonly string[]> =>
187
+ updateStoreAsync(path, (store) => {
188
+ const recovered = store.work
189
+ .filter(
190
+ (work) =>
191
+ work.status === 'claimed' &&
192
+ work.lease?.expiresAt !== undefined &&
193
+ work.lease.expiresAt <= now.toISOString(),
194
+ )
195
+ .map((work) => ({ ...work, lease: undefined, status: 'ready' as const }));
196
+ return {
197
+ result: recovered.map((work) => work.id),
198
+ store: { ...store, work: replaceWork(store.work, recovered) },
199
+ };
200
+ });
201
+
202
+ /** Return a fenced claim to the queue without permitting a stale worker to reschedule it. */
203
+ const releaseWorkAsync = async (
204
+ path: string,
205
+ input: Parameters<FlowPersistencePort['releaseWorkAsync']>[0],
206
+ ): Promise<boolean> =>
207
+ updateStoreAsync(path, (store) => {
208
+ const work = store.work.find((candidate) => candidate.id === input.id);
209
+ if (
210
+ work?.status !== 'claimed' ||
211
+ work.lease?.workerId !== input.workerId ||
212
+ work.lease.fencingToken !== input.leaseFencingToken
213
+ )
214
+ return { result: false, store };
215
+ return {
216
+ result: true,
217
+ store: {
218
+ ...store,
219
+ work: replaceWork(store.work, [
220
+ { ...work, dueAt: input.dueAt, lease: undefined, status: 'ready' },
221
+ ]),
222
+ },
223
+ };
224
+ });
225
+
226
+ /*** Store a revision-checked run transition while preserving its immutable flow reference. */
227
+ const transitionRunAsync = async (
228
+ path: string,
229
+ { expectedRevision, run }: Parameters<FlowPersistencePort['transitionRunAsync']>[0],
230
+ ): Promise<boolean> =>
231
+ updateStoreAsync(path, (store) => {
232
+ const current = store.runs.find((candidate) => candidate.id === run.id);
233
+ if (!sameFlow(current ?? run, run) || current?.revision !== expectedRevision)
234
+ return { result: false, store };
235
+ if (run.revision !== expectedRevision + 1) throw new Error('FLOW_RUN_REVISION_INVALID');
236
+ return { result: true, store: { ...store, runs: replaceRuns(store.runs, [copy(run)]) } };
237
+ });
238
+
239
+ /*** Build a claimed work record with an explicit bounded lease. */
240
+ const claimWork = (
241
+ work: FlowDurableWork,
242
+ input: Parameters<FlowPersistencePort['claimDueWorkAsync']>[0],
243
+ fencingToken: number,
244
+ ): FlowDurableWork => ({
245
+ ...work,
246
+ lease: {
247
+ expiresAt: new Date(input.now.getTime() + input.leaseDurationMs).toISOString(),
248
+ fencingToken,
249
+ workerId: input.workerId,
250
+ },
251
+ status: 'claimed',
252
+ });
253
+
254
+ /*** Replace changed work entries by identity without exposing a mutable adapter collection. */
255
+ const replaceWork = (
256
+ work: readonly FlowDurableWork[],
257
+ replacements: readonly FlowDurableWork[],
258
+ ): readonly FlowDurableWork[] =>
259
+ work.map(
260
+ (candidate) => replacements.find((replacement) => replacement.id === candidate.id) ?? candidate,
261
+ );
262
+
263
+ /*** Replace changed run entries by identity without exposing a mutable adapter collection. */
264
+ const replaceRuns = (
265
+ runs: readonly FlowDurableRun[],
266
+ replacements: readonly FlowDurableRun[],
267
+ ): readonly FlowDurableRun[] =>
268
+ runs.map(
269
+ (candidate) => replacements.find((replacement) => replacement.id === candidate.id) ?? candidate,
270
+ );
271
+
272
+ /** Replace durable outbox records by identity. */
273
+ const replaceOutbox = (
274
+ records: readonly FlowOutboxRecord[],
275
+ replacements: readonly FlowOutboxRecord[],
276
+ ): readonly FlowOutboxRecord[] =>
277
+ records.map(
278
+ (candidate) => replacements.find((replacement) => replacement.id === candidate.id) ?? candidate,
279
+ );
280
+
281
+ /*** Read persisted state or initialize an empty store when it has not yet been created. */
282
+ const readStoreAsync = async (path: string): Promise<FileFlowStore> => {
283
+ try {
284
+ return readFileStore(JSON.parse(await readFile(path, 'utf8')));
285
+ } catch (reason) {
286
+ if (isMissingFile(reason)) return { nextLeaseFencingToken: 1, outbox: [], runs: [], work: [] };
287
+ throw reason;
288
+ }
289
+ };
290
+
291
+ /*** Validate the adapter-owned persistence envelope before using its fencing counter. */
292
+ const readFileStore = (value: unknown): FileFlowStore => {
293
+ if (
294
+ typeof value !== 'object' ||
295
+ value === null ||
296
+ !('nextLeaseFencingToken' in value) ||
297
+ typeof value.nextLeaseFencingToken !== 'number' ||
298
+ !Number.isSafeInteger(value.nextLeaseFencingToken) ||
299
+ value.nextLeaseFencingToken < 1 ||
300
+ !('runs' in value) ||
301
+ !Array.isArray(value.runs) ||
302
+ !('work' in value) ||
303
+ !Array.isArray(value.work) ||
304
+ !('outbox' in value) ||
305
+ !Array.isArray(value.outbox)
306
+ )
307
+ throw new Error('FLOW_PERSISTENCE_INVALID');
308
+ return value as FileFlowStore;
309
+ };
310
+
311
+ /*** Apply one single-process transaction and atomically replace the persisted JSON snapshot. */
312
+ const updateStoreAsync = async <Result>(
313
+ path: string,
314
+ update: (store: FileFlowStore) => { readonly result: Result; readonly store: FileFlowStore },
315
+ ): Promise<Result> => {
316
+ const updated = update(await readStoreAsync(path));
317
+ await mkdir(dirname(path), { recursive: true });
318
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
319
+ await writeFile(temporaryPath, JSON.stringify(updated.store));
320
+ await rename(temporaryPath, path);
321
+ return copy(updated.result);
322
+ };
323
+
324
+ /*** Preserve adapter encapsulation by returning data that callers cannot mutate in-place. */
325
+ const copy = <Value>(value: Value): Value => structuredClone(value);
326
+
327
+ /*** Distinguish an absent first-run store from real filesystem or JSON errors. */
328
+ const isMissingFile = (reason: unknown): reason is NodeJS.ErrnoException =>
329
+ typeof reason === 'object' && reason !== null && 'code' in reason && reason.code === 'ENOENT';
330
+
331
+ /*** Reject a transition that attempts to repoint a run at another immutable flow revision. */
332
+ const sameFlow = (left: FlowDurableRun, right: FlowDurableRun): boolean =>
333
+ left.flow.id === right.flow.id && left.flow.version === right.flow.version;
@@ -0,0 +1,28 @@
1
+ import type { FlowDurableRun } from '../../../types/durableExecution.js';
2
+ import type { FlowClock } from '../../../types/execution.js';
3
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
4
+
5
+ /** Cancel a durable run and atomically prevent future ready or claimed deliveries from starting. */
6
+ export const cancelDurableRunAsync = async (
7
+ runId: string,
8
+ dependencies: { readonly clock: FlowClock; readonly persistence: FlowPersistencePort },
9
+ ): Promise<boolean> => {
10
+ const run = await dependencies.persistence.readRunAsync(runId);
11
+ if (run === undefined || run.status === 'cancelled' || isTerminal(run)) return false;
12
+ const transitioned = await dependencies.persistence.transitionRunAsync({
13
+ expectedRevision: run.revision,
14
+ run: {
15
+ ...run,
16
+ revision: run.revision + 1,
17
+ status: 'cancelled',
18
+ updatedAt: dependencies.clock.now().toISOString(),
19
+ },
20
+ });
21
+ if (!transitioned) return false;
22
+ await dependencies.persistence.cancelRunWorkAsync(runId);
23
+ return true;
24
+ };
25
+
26
+ /** Leave completed and terminally failed runs immutable. */
27
+ const isTerminal = (run: FlowDurableRun): boolean =>
28
+ run.status === 'failed' || run.status === 'succeeded';
@@ -0,0 +1,14 @@
1
+ import type { FlowDefinitionRef, FlowDurableRun } from '../../../types/durableExecution.js';
2
+ import type { FlowClock } from '../../../types/execution.js';
3
+ import { createDurableRun } from '../domain/createDurableRun.js';
4
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
5
+
6
+ /*** Persist a new durable run without executing an operation or provider effect. */
7
+ export const createDurableRunAsync = async (
8
+ input: { readonly flow: FlowDefinitionRef; readonly id: string },
9
+ dependencies: { readonly clock: FlowClock; readonly persistence: FlowPersistencePort },
10
+ ): Promise<FlowDurableRun> => {
11
+ const run = createDurableRun(input, dependencies.clock.now());
12
+ await dependencies.persistence.createRunAsync(run);
13
+ return run;
14
+ };