@plumbus/core 0.2.8 → 0.3.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 (205) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ai/__tests__/ai-service.test.js +704 -1
  3. package/dist/ai/__tests__/ai-service.test.js.map +1 -1
  4. package/dist/ai/__tests__/chunking.test.js +34 -0
  5. package/dist/ai/__tests__/chunking.test.js.map +1 -1
  6. package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
  7. package/dist/ai/__tests__/provider.test.js +482 -4
  8. package/dist/ai/__tests__/provider.test.js.map +1 -1
  9. package/dist/ai/__tests__/validation.test.js +238 -1
  10. package/dist/ai/__tests__/validation.test.js.map +1 -1
  11. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
  12. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
  13. package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
  14. package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
  15. package/dist/ai/ai-service.d.ts +19 -2
  16. package/dist/ai/ai-service.d.ts.map +1 -1
  17. package/dist/ai/ai-service.js +565 -90
  18. package/dist/ai/ai-service.js.map +1 -1
  19. package/dist/ai/cost-tracker.d.ts +26 -1
  20. package/dist/ai/cost-tracker.d.ts.map +1 -1
  21. package/dist/ai/cost-tracker.js +1 -0
  22. package/dist/ai/cost-tracker.js.map +1 -1
  23. package/dist/ai/index.d.ts +5 -3
  24. package/dist/ai/index.d.ts.map +1 -1
  25. package/dist/ai/index.js +5 -1
  26. package/dist/ai/index.js.map +1 -1
  27. package/dist/ai/provider.d.ts +59 -1
  28. package/dist/ai/provider.d.ts.map +1 -1
  29. package/dist/ai/provider.js +388 -90
  30. package/dist/ai/provider.js.map +1 -1
  31. package/dist/ai/rag/chunking.js +53 -3
  32. package/dist/ai/rag/chunking.js.map +1 -1
  33. package/dist/ai/refusal.d.ts +28 -0
  34. package/dist/ai/refusal.d.ts.map +1 -0
  35. package/dist/ai/refusal.js +36 -0
  36. package/dist/ai/refusal.js.map +1 -0
  37. package/dist/ai/validation.d.ts +40 -8
  38. package/dist/ai/validation.d.ts.map +1 -1
  39. package/dist/ai/validation.js +212 -6
  40. package/dist/ai/validation.js.map +1 -1
  41. package/dist/ai/zod-to-provider-schema.d.ts +15 -0
  42. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
  43. package/dist/ai/zod-to-provider-schema.js +200 -0
  44. package/dist/ai/zod-to-provider-schema.js.map +1 -0
  45. package/dist/api/__tests__/route-generator.test.js +3 -0
  46. package/dist/api/__tests__/route-generator.test.js.map +1 -1
  47. package/dist/api/route-generator.d.ts +4 -0
  48. package/dist/api/route-generator.d.ts.map +1 -1
  49. package/dist/api/route-generator.js +1 -0
  50. package/dist/api/route-generator.js.map +1 -1
  51. package/dist/cli/__tests__/doctor.test.js +104 -2
  52. package/dist/cli/__tests__/doctor.test.js.map +1 -1
  53. package/dist/cli/__tests__/init.test.js +127 -7
  54. package/dist/cli/__tests__/init.test.js.map +1 -1
  55. package/dist/cli/__tests__/migrate.test.js +159 -2
  56. package/dist/cli/__tests__/migrate.test.js.map +1 -1
  57. package/dist/cli/commands/__tests__/commands.test.js +5 -0
  58. package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
  59. package/dist/cli/commands/dev.d.ts.map +1 -1
  60. package/dist/cli/commands/dev.js +22 -1
  61. package/dist/cli/commands/dev.js.map +1 -1
  62. package/dist/cli/commands/doctor.d.ts +2 -0
  63. package/dist/cli/commands/doctor.d.ts.map +1 -1
  64. package/dist/cli/commands/doctor.js +78 -0
  65. package/dist/cli/commands/doctor.js.map +1 -1
  66. package/dist/cli/commands/generate.d.ts.map +1 -1
  67. package/dist/cli/commands/generate.js +8 -3
  68. package/dist/cli/commands/generate.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts +16 -2
  70. package/dist/cli/commands/init.d.ts.map +1 -1
  71. package/dist/cli/commands/init.js +226 -25
  72. package/dist/cli/commands/init.js.map +1 -1
  73. package/dist/cli/commands/migrate.d.ts.map +1 -1
  74. package/dist/cli/commands/migrate.js +258 -8
  75. package/dist/cli/commands/migrate.js.map +1 -1
  76. package/dist/cli/commands/start.d.ts.map +1 -1
  77. package/dist/cli/commands/start.js +22 -1
  78. package/dist/cli/commands/start.js.map +1 -1
  79. package/dist/config/loader.d.ts.map +1 -1
  80. package/dist/config/loader.js +3 -0
  81. package/dist/config/loader.js.map +1 -1
  82. package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
  83. package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
  84. package/dist/data/__tests__/drift-inspector.test.js +170 -0
  85. package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
  86. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
  87. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
  88. package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
  89. package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
  90. package/dist/data/__tests__/migration.test.d.ts +2 -0
  91. package/dist/data/__tests__/migration.test.d.ts.map +1 -0
  92. package/dist/data/__tests__/migration.test.js +143 -0
  93. package/dist/data/__tests__/migration.test.js.map +1 -0
  94. package/dist/data/__tests__/repository.test.js +126 -0
  95. package/dist/data/__tests__/repository.test.js.map +1 -1
  96. package/dist/data/drift-inspector.d.ts +41 -0
  97. package/dist/data/drift-inspector.d.ts.map +1 -0
  98. package/dist/data/drift-inspector.js +197 -0
  99. package/dist/data/drift-inspector.js.map +1 -0
  100. package/dist/data/framework-migration-rewriter.d.ts +61 -0
  101. package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
  102. package/dist/data/framework-migration-rewriter.js +357 -0
  103. package/dist/data/framework-migration-rewriter.js.map +1 -0
  104. package/dist/data/index.d.ts +2 -1
  105. package/dist/data/index.d.ts.map +1 -1
  106. package/dist/data/index.js +2 -1
  107. package/dist/data/index.js.map +1 -1
  108. package/dist/data/migration.d.ts +31 -1
  109. package/dist/data/migration.d.ts.map +1 -1
  110. package/dist/data/migration.js +127 -3
  111. package/dist/data/migration.js.map +1 -1
  112. package/dist/data/repository.d.ts.map +1 -1
  113. package/dist/data/repository.js +21 -0
  114. package/dist/data/repository.js.map +1 -1
  115. package/dist/define/__tests__/definePrompt.test.js +37 -0
  116. package/dist/define/__tests__/definePrompt.test.js.map +1 -1
  117. package/dist/define/definePrompt.d.ts +13 -0
  118. package/dist/define/definePrompt.d.ts.map +1 -1
  119. package/dist/define/definePrompt.js +3 -0
  120. package/dist/define/definePrompt.js.map +1 -1
  121. package/dist/errors/__tests__/http.test.js +3 -0
  122. package/dist/errors/__tests__/http.test.js.map +1 -1
  123. package/dist/errors/http.d.ts.map +1 -1
  124. package/dist/errors/http.js +14 -0
  125. package/dist/errors/http.js.map +1 -1
  126. package/dist/errors/index.d.ts +12 -0
  127. package/dist/errors/index.d.ts.map +1 -1
  128. package/dist/errors/index.js +21 -0
  129. package/dist/errors/index.js.map +1 -1
  130. package/dist/events/__tests__/event-emitter.test.js +72 -0
  131. package/dist/events/__tests__/event-emitter.test.js.map +1 -1
  132. package/dist/events/emitter.d.ts.map +1 -1
  133. package/dist/events/emitter.js +51 -0
  134. package/dist/events/emitter.js.map +1 -1
  135. package/dist/execution/__tests__/capability-executor.test.js +28 -0
  136. package/dist/execution/__tests__/capability-executor.test.js.map +1 -1
  137. package/dist/execution/__tests__/context-factory.test.js +1 -0
  138. package/dist/execution/__tests__/context-factory.test.js.map +1 -1
  139. package/dist/execution/capability-executor.d.ts.map +1 -1
  140. package/dist/execution/capability-executor.js +21 -0
  141. package/dist/execution/capability-executor.js.map +1 -1
  142. package/dist/execution/context-factory.d.ts.map +1 -1
  143. package/dist/execution/context-factory.js +2 -0
  144. package/dist/execution/context-factory.js.map +1 -1
  145. package/dist/flows/__tests__/flow-engine.test.js +535 -3
  146. package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
  147. package/dist/flows/__tests__/flow-service.test.js +8 -0
  148. package/dist/flows/__tests__/flow-service.test.js.map +1 -1
  149. package/dist/flows/__tests__/step-executor.test.js +14 -4
  150. package/dist/flows/__tests__/step-executor.test.js.map +1 -1
  151. package/dist/flows/engine.d.ts +53 -1
  152. package/dist/flows/engine.d.ts.map +1 -1
  153. package/dist/flows/engine.js +462 -25
  154. package/dist/flows/engine.js.map +1 -1
  155. package/dist/flows/flow-service.d.ts.map +1 -1
  156. package/dist/flows/flow-service.js +4 -0
  157. package/dist/flows/flow-service.js.map +1 -1
  158. package/dist/flows/index.d.ts +1 -1
  159. package/dist/flows/index.d.ts.map +1 -1
  160. package/dist/flows/index.js +1 -1
  161. package/dist/flows/index.js.map +1 -1
  162. package/dist/flows/schema.d.ts +34 -0
  163. package/dist/flows/schema.d.ts.map +1 -1
  164. package/dist/flows/schema.js +3 -0
  165. package/dist/flows/schema.js.map +1 -1
  166. package/dist/flows/step-executor.d.ts +2 -0
  167. package/dist/flows/step-executor.d.ts.map +1 -1
  168. package/dist/flows/step-executor.js +6 -6
  169. package/dist/flows/step-executor.js.map +1 -1
  170. package/dist/index.d.ts +4 -4
  171. package/dist/index.d.ts.map +1 -1
  172. package/dist/index.js +3 -3
  173. package/dist/index.js.map +1 -1
  174. package/dist/server/__tests__/bootstrap.test.js +37 -0
  175. package/dist/server/__tests__/bootstrap.test.js.map +1 -1
  176. package/dist/server/bootstrap.d.ts +19 -0
  177. package/dist/server/bootstrap.d.ts.map +1 -1
  178. package/dist/server/bootstrap.js +48 -4
  179. package/dist/server/bootstrap.js.map +1 -1
  180. package/dist/testing/__tests__/simulate-flow.test.js +24 -2
  181. package/dist/testing/__tests__/simulate-flow.test.js.map +1 -1
  182. package/dist/testing/context.d.ts.map +1 -1
  183. package/dist/testing/context.js +37 -1
  184. package/dist/testing/context.js.map +1 -1
  185. package/dist/testing/simulate-flow.d.ts.map +1 -1
  186. package/dist/testing/simulate-flow.js +25 -1
  187. package/dist/testing/simulate-flow.js.map +1 -1
  188. package/dist/types/context.d.ts +118 -0
  189. package/dist/types/context.d.ts.map +1 -1
  190. package/dist/types/enums.d.ts +2 -0
  191. package/dist/types/enums.d.ts.map +1 -1
  192. package/dist/types/enums.js +2 -0
  193. package/dist/types/enums.js.map +1 -1
  194. package/dist/types/prompt.d.ts +64 -0
  195. package/dist/types/prompt.d.ts.map +1 -1
  196. package/dist/worker/__tests__/bootstrap.test.js +36 -2
  197. package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
  198. package/dist/worker/bootstrap.d.ts +7 -0
  199. package/dist/worker/bootstrap.d.ts.map +1 -1
  200. package/dist/worker/bootstrap.js +164 -14
  201. package/dist/worker/bootstrap.js.map +1 -1
  202. package/instructions/framework.md +7 -0
  203. package/instructions/guardrails.md +39 -0
  204. package/instructions/patterns.md +10 -0
  205. package/package.json +3 -2
@@ -1,15 +1,64 @@
1
- import { and, eq, inArray, isNull, lte, or } from 'drizzle-orm';
1
+ import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import { FlowCancelledError, LeaseLostError } from '../errors/index.js';
3
5
  import { BackoffStrategy } from '../types/enums.js';
4
6
  import { flowExecutionsTable } from './schema.js';
5
7
  import { assertTransition, FlowStatus, isTerminal, StepStatus, } from './state-machine.js';
6
- import { buildHistoryEntry, executeStep } from './step-executor.js';
8
+ import { buildHistoryEntry, executeStep, } from './step-executor.js';
9
+ /** Default lease duration: 5 minutes */
10
+ const DEFAULT_LEASE_DURATION_MS = 300_000;
11
+ /** Default claim batch size */
12
+ const DEFAULT_CLAIM_BATCH_SIZE = 50;
13
+ /**
14
+ * Read the affected-row count from a Drizzle UPDATE/INSERT/DELETE result in a
15
+ * driver-agnostic way. postgres-js exposes this as `.count` (the array-like
16
+ * `Result` has `length === 0` when no RETURNING clause is used), while
17
+ * node-postgres exposes it as `.rowCount`. Returning `length` is only a
18
+ * correct fallback for queries that include `.returning(...)`.
19
+ *
20
+ * Historical bug: reading only `rowCount ?? length` silently returned 0 for
21
+ * every postgres-js UPDATE without RETURNING, which caused `extendLease` to
22
+ * always report "lease lost" even when the row was successfully updated.
23
+ */
24
+ function getRowsAffected(result) {
25
+ const r = result;
26
+ if (typeof r?.rowCount === 'number')
27
+ return r.rowCount;
28
+ if (typeof r?.count === 'number')
29
+ return r.count;
30
+ if (typeof r?.length === 'number')
31
+ return r.length;
32
+ return 0;
33
+ }
34
+ /**
35
+ * Generate a deterministic-ish worker id for the current process.
36
+ */
37
+ export function generateWorkerId() {
38
+ return `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
39
+ }
7
40
  /**
8
41
  * Creates the flow execution engine that manages flow lifecycle:
9
42
  * start, run steps, handle wait/resume, and persist state.
43
+ *
44
+ * Supports lease-based claiming to prevent duplicate step execution
45
+ * across multiple workers sharing the same database.
10
46
  */
11
47
  export function createFlowEngine(config) {
12
- const { db, registry, stepDeps, audit, onFlowError } = config;
48
+ const { db, registry, stepDeps, audit, onFlowError, createEventService } = config;
49
+ const workerId = config.workerId ?? generateWorkerId();
50
+ const leaseDurationMs = config.flowLeaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
51
+ const heartbeatIntervalMs = config.flowHeartbeatIntervalMs ?? Math.floor(leaseDurationMs / 3);
52
+ const claimBatchSize = config.flowClaimBatchSize ?? DEFAULT_CLAIM_BATCH_SIZE;
53
+ const logger = config.logger;
54
+ /**
55
+ * AbortControllers for currently-executing steps in THIS engine instance,
56
+ * keyed by executionId. `cancel()` uses this to abort the running step
57
+ * synchronously when the cancel request arrives in the same process as
58
+ * the worker. Cross-process cancels still converge via the next heartbeat
59
+ * (which now also fails when status != 'running').
60
+ */
61
+ const activeStepAborts = new Map();
13
62
  /**
14
63
  * Start a new flow execution.
15
64
  */
@@ -24,7 +73,12 @@ export function createFlowEngine(config) {
24
73
  throw new Error(`Flow "${flowName}": invalid input — ${parseResult.error.message}`);
25
74
  }
26
75
  const executionId = randomUUID();
27
- const initialState = flow.state ? {} : null;
76
+ const initialState = flow.state
77
+ ? (() => {
78
+ const parsed = flow.state.safeParse({});
79
+ return parsed.success ? parsed.data : {};
80
+ })()
81
+ : null;
28
82
  await db.insert(flowExecutionsTable).values({
29
83
  id: executionId,
30
84
  flowName,
@@ -54,9 +108,125 @@ export function createFlowEngine(config) {
54
108
  status: FlowStatus.Created,
55
109
  };
56
110
  }
111
+ /**
112
+ * Atomically claim up to `batchSize` runnable flow executions.
113
+ * Uses FOR UPDATE SKIP LOCKED so concurrent workers each get different rows.
114
+ * Also reclaims rows whose lease has expired (crash recovery).
115
+ */
116
+ async function claimNext(batchSize) {
117
+ const limit = batchSize ?? claimBatchSize;
118
+ const leaseDurationInterval = `${leaseDurationMs} milliseconds`;
119
+ const result = await db.execute(sql `
120
+ UPDATE flow_executions
121
+ SET status = ${FlowStatus.Running},
122
+ lease_owner = ${workerId},
123
+ lease_expires_at = now() + ${leaseDurationInterval}::interval,
124
+ updated_at = now()
125
+ WHERE id IN (
126
+ SELECT id
127
+ FROM flow_executions
128
+ WHERE
129
+ status = ${FlowStatus.Created}
130
+ OR (status = ${FlowStatus.Running} AND lease_expires_at < now())
131
+ OR (status = ${FlowStatus.Waiting} AND waiting_for_event IS NULL AND wake_at <= now())
132
+ ORDER BY created_at
133
+ FOR UPDATE SKIP LOCKED
134
+ LIMIT ${limit}
135
+ )
136
+ RETURNING *
137
+ `);
138
+ const rows = result;
139
+ for (const row of rows) {
140
+ if (audit) {
141
+ const wasExpired = row.status === FlowStatus.Running;
142
+ if (wasExpired) {
143
+ await audit.record('flow.lease.expired', {
144
+ executionId: row.id,
145
+ flowName: row.flowName,
146
+ workerId,
147
+ previousOwner: row.leaseOwner,
148
+ step: row.currentStep,
149
+ });
150
+ }
151
+ await audit.record('flow.lease.claimed', {
152
+ executionId: row.id,
153
+ flowName: row.flowName,
154
+ workerId,
155
+ step: row.currentStep,
156
+ });
157
+ }
158
+ }
159
+ return rows;
160
+ }
161
+ /**
162
+ * Extend the lease for a flow execution.
163
+ * Returns true if the lease was extended, false if the lease was lost.
164
+ *
165
+ * The WHERE clause also requires `status = 'running'`. If another actor
166
+ * (e.g. `cancel()`) has flipped the status to `cancelled`/`failed`, the
167
+ * UPDATE matches 0 rows and we take the same "lease lost" path — which
168
+ * the heartbeat uses to abort the per-step AbortController so the running
169
+ * capability can cooperatively stop its in-flight AI / HTTP work.
170
+ */
171
+ async function extendLease(executionId) {
172
+ const leaseDurationInterval = `${leaseDurationMs} milliseconds`;
173
+ const result = await db
174
+ .update(flowExecutionsTable)
175
+ .set({
176
+ leaseExpiresAt: sql `now() + ${leaseDurationInterval}::interval`,
177
+ updatedAt: new Date(),
178
+ })
179
+ .where(and(eq(flowExecutionsTable.id, executionId), eq(flowExecutionsTable.leaseOwner, workerId), eq(flowExecutionsTable.status, FlowStatus.Running)));
180
+ const rowsAffected = getRowsAffected(result);
181
+ if (rowsAffected === 0 && logger) {
182
+ // D1 diagnostic: the heartbeat's status+owner guard didn't match, which
183
+ // means something mutated the row out from under us (status flipped by
184
+ // cancel/complete/failure, or a different worker stole the lease). Read
185
+ // the row back and log its actual shape plus our own identity so we can
186
+ // tell at a glance which dimension diverged. Best-effort: a failure
187
+ // here must not break the heartbeat.
188
+ try {
189
+ const rows = await db
190
+ .select({
191
+ status: flowExecutionsTable.status,
192
+ leaseOwner: flowExecutionsTable.leaseOwner,
193
+ leaseExpiresAt: flowExecutionsTable.leaseExpiresAt,
194
+ updatedAt: flowExecutionsTable.updatedAt,
195
+ currentStep: flowExecutionsTable.currentStep,
196
+ })
197
+ .from(flowExecutionsTable)
198
+ .where(eq(flowExecutionsTable.id, executionId))
199
+ .limit(1);
200
+ const row = rows[0];
201
+ logger.warn('extendLease matched 0 rows', {
202
+ executionId,
203
+ expectedWorkerId: workerId,
204
+ expectedStatus: FlowStatus.Running,
205
+ actual: row
206
+ ? {
207
+ status: row.status,
208
+ leaseOwner: row.leaseOwner,
209
+ leaseExpiresAt: row.leaseExpiresAt?.toISOString() ?? null,
210
+ updatedAt: row.updatedAt?.toISOString() ?? null,
211
+ currentStep: row.currentStep,
212
+ }
213
+ : null,
214
+ nowMs: Date.now(),
215
+ });
216
+ }
217
+ catch (diagErr) {
218
+ logger.warn('extendLease diagnostic SELECT failed', {
219
+ executionId,
220
+ error: diagErr instanceof Error ? diagErr.message : String(diagErr),
221
+ });
222
+ }
223
+ }
224
+ return rowsAffected > 0;
225
+ }
57
226
  /**
58
227
  * Run the next step(s) for a flow execution.
59
228
  * This is designed to be called by a worker process.
229
+ * The execution must have been claimed by this worker (via claimNext) first.
60
230
  */
61
231
  async function runNext(executionId, ctx) {
62
232
  const rows = await db
@@ -82,13 +252,15 @@ export function createFlowEngine(config) {
82
252
  if (row.status === FlowStatus.Waiting && row.wakeAt && row.wakeAt.getTime() > Date.now()) {
83
253
  return { id: row.id, flowName: row.flowName, status: FlowStatus.Waiting };
84
254
  }
85
- // Transition to running
255
+ // Transition to running (only if not already claimed via claimNext)
86
256
  if (row.status === FlowStatus.Created || row.status === FlowStatus.Waiting) {
87
257
  assertTransition(row.status, FlowStatus.Running);
88
- await updateExecution(executionId, {
258
+ await guardedUpdate(executionId, {
89
259
  status: FlowStatus.Running,
90
260
  waitingForEvent: null,
91
261
  wakeAt: null,
262
+ leaseOwner: workerId,
263
+ leaseExpiresAt: new Date(Date.now() + leaseDurationMs),
92
264
  });
93
265
  }
94
266
  // Find current step
@@ -111,21 +283,126 @@ export function createFlowEngine(config) {
111
283
  internal: true,
112
284
  };
113
285
  const flowData = config.createDataService ? config.createDataService(flowAuth) : ctx.data;
286
+ const flowEvents = createEventService ? createEventService(flowAuth) : ctx.events;
287
+ // Create a heartbeat function for manual use by step handlers
288
+ const heartbeatFn = async () => {
289
+ const extended = await extendLease(executionId);
290
+ if (!extended) {
291
+ throw new LeaseLostError(`Lease lost for flow execution "${executionId}" (worker: ${workerId})`, { executionId, workerId, step: currentStepName });
292
+ }
293
+ };
294
+ // Per-step AbortController: aborted when the lease is lost (either
295
+ // because another worker claimed the execution OR because cancel() flipped
296
+ // the status out of 'running'). Capability handlers can subscribe via
297
+ // `ctx.signal` and pass it to AI/HTTP calls to stop cooperatively rather
298
+ // than continuing to burn budget as a zombie.
299
+ const stepAc = new AbortController();
300
+ activeStepAborts.set(executionId, stepAc);
301
+ // Auto-thread `signal` into AI calls so capability authors don't have to
302
+ // pass it explicitly. An explicit `signal` in the call overrides the
303
+ // step default. See AIService type for the per-method signal parameter.
304
+ const flowAi = wrapAiWithDefaultSignal(ctx.ai, stepAc.signal);
114
305
  const flowCtx = {
115
306
  ...ctx,
116
307
  auth: flowAuth,
117
308
  data: flowData,
309
+ events: flowEvents,
310
+ ai: flowAi,
118
311
  state: row.state,
119
312
  step: currentStepName,
120
313
  flowId: executionId,
314
+ workerId,
315
+ signal: stepAc.signal,
316
+ flows: {
317
+ ...ctx.flows,
318
+ heartbeat: heartbeatFn,
319
+ },
121
320
  };
321
+ // Start automatic heartbeat timer
322
+ let leaseLost = false;
323
+ let heartbeatTickCount = 0;
324
+ const heartbeatStartedAtMs = Date.now();
325
+ const heartbeatTimer = setInterval(async () => {
326
+ heartbeatTickCount += 1;
327
+ const tick = heartbeatTickCount;
328
+ const firedAtMs = Date.now();
329
+ const expectedFireAtMs = heartbeatStartedAtMs + tick * heartbeatIntervalMs;
330
+ const driftMs = firedAtMs - expectedFireAtMs;
331
+ // D2 diagnostic: emit one log line per heartbeat tick so we can see
332
+ // whether ticks are firing on schedule (event-loop starvation shows up
333
+ // here as large positive `driftMs`). First tick is logged at `info`
334
+ // because that's typically enough for a post-mortem; subsequent ticks
335
+ // at `debug` to avoid log spam for long-running flows.
336
+ if (logger) {
337
+ const payload = {
338
+ executionId,
339
+ workerId,
340
+ step: currentStepName,
341
+ tick,
342
+ firedAtMs,
343
+ expectedFireAtMs,
344
+ driftMs,
345
+ heartbeatIntervalMs,
346
+ };
347
+ if (tick === 1) {
348
+ logger.info('flow.heartbeat.tick', payload);
349
+ }
350
+ else {
351
+ logger.debug('flow.heartbeat.tick', payload);
352
+ }
353
+ }
354
+ const extended = await extendLease(executionId);
355
+ if (extended) {
356
+ if (audit) {
357
+ await audit.record('flow.lease.extended', {
358
+ executionId,
359
+ flowName: row.flowName,
360
+ workerId,
361
+ step: currentStepName,
362
+ });
363
+ }
364
+ }
365
+ else {
366
+ leaseLost = true;
367
+ if (!stepAc.signal.aborted) {
368
+ stepAc.abort(new LeaseLostError(`Lease lost for flow execution "${executionId}" (worker: ${workerId})`, { executionId, workerId, step: currentStepName }));
369
+ }
370
+ if (audit) {
371
+ await audit.record('flow.lease.lost', {
372
+ executionId,
373
+ flowName: row.flowName,
374
+ workerId,
375
+ step: currentStepName,
376
+ });
377
+ }
378
+ }
379
+ }, heartbeatIntervalMs);
122
380
  // Execute the step
381
+ let result;
123
382
  const startedAt = new Date();
124
- const result = await executeStep(step, flowCtx, row.input, row.state, stepDeps);
383
+ try {
384
+ result = await executeStep(step, flowCtx, row.input, row.state, stepDeps);
385
+ }
386
+ finally {
387
+ clearInterval(heartbeatTimer);
388
+ // Abort any stray detached tasks once the step settles so they observe
389
+ // the abort promptly. If already aborted (lease loss), this is a no-op.
390
+ if (!stepAc.signal.aborted) {
391
+ stepAc.abort();
392
+ }
393
+ if (activeStepAborts.get(executionId) === stepAc) {
394
+ activeStepAborts.delete(executionId);
395
+ }
396
+ }
125
397
  const completedAt = new Date();
398
+ // Check if lease was lost during step execution
399
+ if (leaseLost) {
400
+ throw new LeaseLostError(`Lease lost during step "${currentStepName}" of flow execution "${executionId}"`, { executionId, workerId, step: currentStepName });
401
+ }
126
402
  const historyEntry = buildHistoryEntry(currentStepName, result, startedAt, completedAt);
127
403
  const history = Array.isArray(row.stepHistory) ? row.stepHistory : [];
128
404
  history.push(historyEntry);
405
+ const nextState = mergeExecutionState(row.state, result.data);
129
406
  if (audit) {
130
407
  await audit.record(`flow.step.${result.status}.${currentStepName}`, {
131
408
  executionId,
@@ -141,13 +418,23 @@ export function createFlowEngine(config) {
141
418
  }
142
419
  // Wait step — pause the flow
143
420
  if (result.waitEvent) {
144
- await updateExecution(executionId, {
421
+ const ok = await guardedUpdate(executionId, {
145
422
  status: FlowStatus.Waiting,
423
+ state: nextState,
146
424
  stepHistory: history,
147
425
  currentStep: currentStepName, // stay on current step until resumed
148
426
  waitingForEvent: result.waitEvent,
149
427
  wakeAt: null,
428
+ leaseOwner: null,
429
+ leaseExpiresAt: null,
150
430
  });
431
+ if (!ok) {
432
+ throw new LeaseLostError(`Lease lost committing wait for flow execution "${executionId}"`, {
433
+ executionId,
434
+ workerId,
435
+ step: currentStepName,
436
+ });
437
+ }
151
438
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
152
439
  }
153
440
  // Delay step — schedule next step after duration
@@ -160,17 +447,21 @@ export function createFlowEngine(config) {
160
447
  catch (err) {
161
448
  return handleStepFailure(executionId, row, flow, history, err instanceof Error ? err.message : String(err));
162
449
  }
163
- await updateExecution(executionId, {
450
+ await guardedUpdate(executionId, {
164
451
  status: FlowStatus.Waiting,
452
+ state: nextState,
165
453
  stepHistory: history,
166
454
  currentStep: nextStep,
167
455
  waitingForEvent: null,
168
456
  wakeAt: new Date(Date.now() + delayMs),
457
+ leaseOwner: null,
458
+ leaseExpiresAt: null,
169
459
  });
170
460
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
171
461
  }
172
462
  // Parallel step — execute all branches concurrently, then advance
173
463
  if (result.parallelBranches) {
464
+ let parallelState = nextState;
174
465
  const branchSteps = result.parallelBranches
175
466
  .map((branchName) => flow.steps.find((s) => s.name === branchName))
176
467
  .filter((s) => s != null);
@@ -185,6 +476,7 @@ export function createFlowEngine(config) {
185
476
  if (settled.status === 'fulfilled') {
186
477
  const { branchStep, branchResult, branchStart, branchEnd } = settled.value;
187
478
  history.push(buildHistoryEntry(branchStep.name, branchResult, branchStart, branchEnd));
479
+ parallelState = mergeExecutionState(parallelState, branchResult.data);
188
480
  }
189
481
  }
190
482
  const anyFailed = branchResults.some((r) => r.status === 'rejected' ||
@@ -193,7 +485,8 @@ export function createFlowEngine(config) {
193
485
  return handleStepFailure(executionId, row, flow, history, 'One or more parallel branches failed');
194
486
  }
195
487
  const nextStep = getNextStepName(flow.steps, currentStepName);
196
- await updateExecution(executionId, {
488
+ await guardedUpdate(executionId, {
489
+ state: parallelState,
197
490
  stepHistory: history,
198
491
  currentStep: nextStep,
199
492
  });
@@ -205,7 +498,8 @@ export function createFlowEngine(config) {
205
498
  }
206
499
  // Conditional step — jump to the chosen branch
207
500
  if (result.nextStep) {
208
- await updateExecution(executionId, {
501
+ await guardedUpdate(executionId, {
502
+ state: nextState,
209
503
  stepHistory: history,
210
504
  currentStep: result.nextStep,
211
505
  });
@@ -214,11 +508,12 @@ export function createFlowEngine(config) {
214
508
  // Normal completion — advance to next step
215
509
  const nextStep = getNextStepName(flow.steps, currentStepName);
216
510
  if (!nextStep) {
217
- await updateExecution(executionId, { stepHistory: history });
511
+ await guardedUpdate(executionId, { state: nextState, stepHistory: history });
218
512
  await completeFlow(executionId, row.flowName);
219
513
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Completed };
220
514
  }
221
- await updateExecution(executionId, {
515
+ await guardedUpdate(executionId, {
516
+ state: nextState,
222
517
  stepHistory: history,
223
518
  currentStep: nextStep,
224
519
  });
@@ -242,27 +537,47 @@ export function createFlowEngine(config) {
242
537
  const flow = registry.get(row.flowName);
243
538
  if (!flow)
244
539
  throw new Error(`Flow "${row.flowName}" not registered`);
245
- // Advance past the wait/delay step
540
+ // Advance past the wait/delay step — set to created so claimNext picks it up
246
541
  const nextStep = getNextStepName(flow.steps, row.currentStep ?? '');
247
542
  await updateExecution(executionId, {
248
- status: FlowStatus.Running,
543
+ status: FlowStatus.Created,
249
544
  currentStep: nextStep,
250
545
  state: signal !== undefined ? signal : row.state,
251
546
  waitingForEvent: null,
252
547
  wakeAt: null,
548
+ leaseOwner: null,
549
+ leaseExpiresAt: null,
253
550
  });
254
551
  }
255
552
  /**
256
553
  * Resume all flows waiting on a specific event type.
554
+ * Uses FOR UPDATE SKIP LOCKED to prevent duplicate resumes across workers.
257
555
  * Returns the number of executions resumed.
258
556
  */
259
557
  async function resumeWaitingByEvent(eventType, signal) {
260
- const rows = await db
261
- .select({ id: flowExecutionsTable.id })
262
- .from(flowExecutionsTable)
263
- .where(and(eq(flowExecutionsTable.status, FlowStatus.Waiting), eq(flowExecutionsTable.waitingForEvent, eventType)));
264
- for (const row of rows) {
265
- await resume(row.id, signal);
558
+ const result = await db.execute(sql `
559
+ UPDATE flow_executions
560
+ SET status = ${FlowStatus.Created},
561
+ waiting_for_event = NULL,
562
+ wake_at = NULL,
563
+ lease_owner = NULL,
564
+ lease_expires_at = NULL,
565
+ updated_at = now()
566
+ WHERE id IN (
567
+ SELECT id
568
+ FROM flow_executions
569
+ WHERE status = ${FlowStatus.Waiting}
570
+ AND waiting_for_event = ${eventType}
571
+ FOR UPDATE SKIP LOCKED
572
+ )
573
+ RETURNING id
574
+ `);
575
+ const rows = result;
576
+ // If a signal was provided, update state for each resumed execution
577
+ if (signal !== undefined) {
578
+ for (const row of rows) {
579
+ await updateExecution(row.id, { state: signal });
580
+ }
266
581
  }
267
582
  return rows.length;
268
583
  }
@@ -298,6 +613,13 @@ export function createFlowEngine(config) {
298
613
  status: FlowStatus.Cancelled,
299
614
  completedAt: new Date(),
300
615
  });
616
+ // Synchronously abort the step's AbortController if it is running in THIS
617
+ // engine instance. Cross-process cancellations still converge via the next
618
+ // heartbeat (which fails because status is no longer 'running').
619
+ const activeAc = activeStepAborts.get(executionId);
620
+ if (activeAc && !activeAc.signal.aborted) {
621
+ activeAc.abort(new FlowCancelledError(executionId));
622
+ }
301
623
  if (audit) {
302
624
  await audit.record(`flow.cancelled.${row.flowName}`, {
303
625
  executionId,
@@ -325,7 +647,7 @@ export function createFlowEngine(config) {
325
647
  const maxRetries = flow.retry?.attempts ?? 0;
326
648
  if (retryCount <= maxRetries) {
327
649
  // Retry: keep current step, increment counter
328
- await updateExecution(executionId, {
650
+ await guardedUpdate(executionId, {
329
651
  stepHistory: history,
330
652
  retryCount,
331
653
  lastError: error ?? null,
@@ -337,21 +659,29 @@ export function createFlowEngine(config) {
337
659
  return { id: executionId, flowName: row.flowName, status: FlowStatus.Failed };
338
660
  }
339
661
  async function completeFlow(executionId, flowName) {
340
- await updateExecution(executionId, {
662
+ const ok = await guardedUpdate(executionId, {
341
663
  status: FlowStatus.Completed,
342
664
  currentStep: null,
343
665
  completedAt: new Date(),
666
+ leaseOwner: null,
667
+ leaseExpiresAt: null,
344
668
  });
669
+ if (!ok)
670
+ return; // Lease lost — another worker owns this execution
345
671
  if (audit) {
346
672
  await audit.record(`flow.completed.${flowName}`, { executionId, flowName });
347
673
  }
348
674
  }
349
675
  async function failFlow(executionId, flowName, error, row) {
350
- await updateExecution(executionId, {
676
+ const ok = await guardedUpdate(executionId, {
351
677
  status: FlowStatus.Failed,
352
678
  lastError: error ?? 'Unknown error',
353
679
  completedAt: new Date(),
680
+ leaseOwner: null,
681
+ leaseExpiresAt: null,
354
682
  });
683
+ if (!ok)
684
+ return; // Lease lost — another worker owns this execution
355
685
  if (audit) {
356
686
  await audit.record(`flow.failed.${flowName}`, {
357
687
  executionId,
@@ -368,6 +698,7 @@ export function createFlowEngine(config) {
368
698
  error: error ?? 'Unknown error',
369
699
  tenantId: row?.tenantId,
370
700
  actor: row?.actor,
701
+ db,
371
702
  });
372
703
  }
373
704
  catch {
@@ -381,7 +712,113 @@ export function createFlowEngine(config) {
381
712
  .set({ ...updates, updatedAt: new Date() })
382
713
  .where(eq(flowExecutionsTable.id, id));
383
714
  }
384
- return { start, runNext, resume, resumeWaitingByEvent, listRunnable, cancel, status };
715
+ /**
716
+ * Finalize an execution as `failed` from the flow runner's perspective when
717
+ * `runNext` rejects with an unhandled error (e.g. `LeaseLostError` from a
718
+ * heartbeat). Without this, a zombie row left by a previously-crashed worker
719
+ * would be re-claimed by `claimNext` on every poll cycle (via the
720
+ * `status='running' AND lease_expires_at < now()` crash-recovery branch),
721
+ * retry forever, and pile up log noise.
722
+ *
723
+ * The guard is `status IN ('running','created','waiting')` (a set-guard, NOT
724
+ * a `lease_owner = workerId` check): on `LeaseLostError` the lease is by
725
+ * definition no longer ours, and we still want to stop the loop. A
726
+ * concurrent successful completion or an explicit `cancel()` will have
727
+ * already moved the row to a terminal status, and the `IN (...)` clause
728
+ * turns this call into a no-op in those races.
729
+ *
730
+ * Idempotent: second call on an already-finalized row is a no-op.
731
+ */
732
+ async function markFailedFromRunner(executionId, error) {
733
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
734
+ const result = await db
735
+ .update(flowExecutionsTable)
736
+ .set({
737
+ status: FlowStatus.Failed,
738
+ lastError: message,
739
+ completedAt: new Date(),
740
+ leaseOwner: null,
741
+ leaseExpiresAt: null,
742
+ updatedAt: new Date(),
743
+ })
744
+ .where(and(eq(flowExecutionsTable.id, executionId), inArray(flowExecutionsTable.status, [
745
+ FlowStatus.Running,
746
+ FlowStatus.Created,
747
+ FlowStatus.Waiting,
748
+ ])));
749
+ const rowsAffected = getRowsAffected(result);
750
+ if (rowsAffected > 0 && audit) {
751
+ await audit.record('flow.runner.finalized', {
752
+ executionId,
753
+ workerId,
754
+ error: message,
755
+ });
756
+ }
757
+ return rowsAffected > 0;
758
+ }
759
+ /**
760
+ * Update execution with lease ownership guard.
761
+ * Returns true if the update succeeded (we still own the lease),
762
+ * false if the lease was lost (0 rows affected).
763
+ */
764
+ async function guardedUpdate(id, updates) {
765
+ const result = await db
766
+ .update(flowExecutionsTable)
767
+ .set({ ...updates, updatedAt: new Date() })
768
+ .where(and(eq(flowExecutionsTable.id, id), eq(flowExecutionsTable.leaseOwner, workerId)));
769
+ const rowsAffected = getRowsAffected(result);
770
+ return rowsAffected > 0;
771
+ }
772
+ return {
773
+ start,
774
+ runNext,
775
+ resume,
776
+ resumeWaitingByEvent,
777
+ listRunnable,
778
+ claimNext,
779
+ cancel,
780
+ status,
781
+ markFailedFromRunner,
782
+ get workerId() {
783
+ return workerId;
784
+ },
785
+ };
786
+ }
787
+ /**
788
+ * Wrap an AIService so that every method defaults `signal` to the flow step's
789
+ * AbortSignal when the caller doesn't pass one explicitly. `streamGenerate`
790
+ * is a sync function returning an AsyncIterable and is wrapped accordingly.
791
+ */
792
+ function wrapAiWithDefaultSignal(ai, defaultSignal) {
793
+ const withSignal = (params) => ({
794
+ ...params,
795
+ signal: params.signal ?? defaultSignal,
796
+ });
797
+ return {
798
+ generate: (params) => ai.generate(withSignal(params)),
799
+ generateWithUsage: (params) => ai.generateWithUsage(withSignal(params)),
800
+ streamGenerate: (params) => ai.streamGenerate(withSignal(params)),
801
+ extract: (params) => ai.extract(withSignal(params)),
802
+ classify: (params) => ai.classify(withSignal(params)),
803
+ retrieve: (params) => ai.retrieve(withSignal(params)),
804
+ };
805
+ }
806
+ function mergeExecutionState(currentState, stepData) {
807
+ if (stepData === undefined) {
808
+ return currentState;
809
+ }
810
+ if (currentState &&
811
+ typeof currentState === 'object' &&
812
+ !Array.isArray(currentState) &&
813
+ stepData &&
814
+ typeof stepData === 'object' &&
815
+ !Array.isArray(stepData)) {
816
+ return {
817
+ ...currentState,
818
+ ...stepData,
819
+ };
820
+ }
821
+ return stepData;
385
822
  }
386
823
  /**
387
824
  * Collect all step names that are branches of parallel steps.