@plumbus/core 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/ai/__tests__/ai-service.test.js +704 -1
- package/dist/ai/__tests__/ai-service.test.js.map +1 -1
- package/dist/ai/__tests__/chunking.test.js +34 -0
- package/dist/ai/__tests__/chunking.test.js.map +1 -1
- package/dist/ai/__tests__/model-pricing.test.js +45 -1
- package/dist/ai/__tests__/model-pricing.test.js.map +1 -1
- package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
- package/dist/ai/__tests__/provider.test.js +730 -1
- package/dist/ai/__tests__/provider.test.js.map +1 -1
- package/dist/ai/__tests__/validation.test.js +200 -1
- package/dist/ai/__tests__/validation.test.js.map +1 -1
- package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
- package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
- package/dist/ai/ai-service.d.ts +19 -2
- package/dist/ai/ai-service.d.ts.map +1 -1
- package/dist/ai/ai-service.js +544 -110
- package/dist/ai/ai-service.js.map +1 -1
- package/dist/ai/cost-tracker.d.ts +26 -1
- package/dist/ai/cost-tracker.d.ts.map +1 -1
- package/dist/ai/cost-tracker.js +1 -0
- package/dist/ai/cost-tracker.js.map +1 -1
- package/dist/ai/index.d.ts +6 -4
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +7 -3
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/model-pricing.d.ts +24 -1
- package/dist/ai/model-pricing.d.ts.map +1 -1
- package/dist/ai/model-pricing.js +83 -56
- package/dist/ai/model-pricing.js.map +1 -1
- package/dist/ai/provider.d.ts +110 -1
- package/dist/ai/provider.d.ts.map +1 -1
- package/dist/ai/provider.js +358 -32
- package/dist/ai/provider.js.map +1 -1
- package/dist/ai/rag/chunking.js +53 -3
- package/dist/ai/rag/chunking.js.map +1 -1
- package/dist/ai/refusal.d.ts +28 -0
- package/dist/ai/refusal.d.ts.map +1 -0
- package/dist/ai/refusal.js +36 -0
- package/dist/ai/refusal.js.map +1 -0
- package/dist/ai/validation.d.ts +40 -8
- package/dist/ai/validation.d.ts.map +1 -1
- package/dist/ai/validation.js +97 -6
- package/dist/ai/validation.js.map +1 -1
- package/dist/ai/zod-to-provider-schema.d.ts +15 -0
- package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
- package/dist/ai/zod-to-provider-schema.js +200 -0
- package/dist/ai/zod-to-provider-schema.js.map +1 -0
- package/dist/cli/__tests__/doctor.test.js +104 -2
- package/dist/cli/__tests__/doctor.test.js.map +1 -1
- package/dist/cli/__tests__/init.test.js +127 -7
- package/dist/cli/__tests__/init.test.js.map +1 -1
- package/dist/cli/__tests__/migrate.test.js +159 -2
- package/dist/cli/__tests__/migrate.test.js.map +1 -1
- package/dist/cli/commands/__tests__/commands.test.js +5 -0
- package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +21 -1
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +78 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +7 -2
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/init.d.ts +16 -2
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +226 -25
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +258 -8
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +21 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
- package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
- package/dist/data/__tests__/drift-inspector.test.js +170 -0
- package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
- package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
- package/dist/data/__tests__/migration.test.js +93 -2
- package/dist/data/__tests__/migration.test.js.map +1 -1
- package/dist/data/__tests__/repository.test.js +126 -0
- package/dist/data/__tests__/repository.test.js.map +1 -1
- package/dist/data/drift-inspector.d.ts +41 -0
- package/dist/data/drift-inspector.d.ts.map +1 -0
- package/dist/data/drift-inspector.js +197 -0
- package/dist/data/drift-inspector.js.map +1 -0
- package/dist/data/framework-migration-rewriter.d.ts +61 -0
- package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
- package/dist/data/framework-migration-rewriter.js +357 -0
- package/dist/data/framework-migration-rewriter.js.map +1 -0
- package/dist/data/index.d.ts +2 -1
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +2 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/migration.d.ts +31 -1
- package/dist/data/migration.d.ts.map +1 -1
- package/dist/data/migration.js +113 -3
- package/dist/data/migration.js.map +1 -1
- package/dist/data/repository.d.ts.map +1 -1
- package/dist/data/repository.js +21 -0
- package/dist/data/repository.js.map +1 -1
- package/dist/define/__tests__/definePrompt.test.js +37 -0
- package/dist/define/__tests__/definePrompt.test.js.map +1 -1
- package/dist/define/definePrompt.d.ts +13 -0
- package/dist/define/definePrompt.d.ts.map +1 -1
- package/dist/define/definePrompt.js +3 -0
- package/dist/define/definePrompt.js.map +1 -1
- package/dist/errors/http.d.ts.map +1 -1
- package/dist/errors/http.js +2 -0
- package/dist/errors/http.js.map +1 -1
- package/dist/errors/index.d.ts +12 -0
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +21 -0
- package/dist/errors/index.js.map +1 -1
- package/dist/events/__tests__/event-emitter.test.js +72 -0
- package/dist/events/__tests__/event-emitter.test.js.map +1 -1
- package/dist/events/emitter.d.ts.map +1 -1
- package/dist/events/emitter.js +51 -0
- package/dist/events/emitter.js.map +1 -1
- package/dist/execution/__tests__/context-factory.test.js +1 -0
- package/dist/execution/__tests__/context-factory.test.js.map +1 -1
- package/dist/execution/context-factory.d.ts.map +1 -1
- package/dist/execution/context-factory.js +2 -0
- package/dist/execution/context-factory.js.map +1 -1
- package/dist/flows/__tests__/flow-engine.test.js +451 -4
- package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
- package/dist/flows/__tests__/flow-service.test.js +8 -0
- package/dist/flows/__tests__/flow-service.test.js.map +1 -1
- package/dist/flows/__tests__/step-executor.test.js +8 -2
- package/dist/flows/__tests__/step-executor.test.js.map +1 -1
- package/dist/flows/engine.d.ts +50 -1
- package/dist/flows/engine.d.ts.map +1 -1
- package/dist/flows/engine.js +427 -23
- package/dist/flows/engine.js.map +1 -1
- package/dist/flows/flow-service.d.ts.map +1 -1
- package/dist/flows/flow-service.js +4 -0
- package/dist/flows/flow-service.js.map +1 -1
- package/dist/flows/index.d.ts +1 -1
- package/dist/flows/index.d.ts.map +1 -1
- package/dist/flows/index.js +1 -1
- package/dist/flows/index.js.map +1 -1
- package/dist/flows/schema.d.ts +34 -0
- package/dist/flows/schema.d.ts.map +1 -1
- package/dist/flows/schema.js +3 -0
- package/dist/flows/schema.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/server/bootstrap.d.ts +14 -0
- package/dist/server/bootstrap.d.ts.map +1 -1
- package/dist/server/bootstrap.js +23 -4
- package/dist/server/bootstrap.js.map +1 -1
- package/dist/testing/context.d.ts.map +1 -1
- package/dist/testing/context.js +37 -1
- package/dist/testing/context.js.map +1 -1
- package/dist/types/context.d.ts +110 -0
- package/dist/types/context.d.ts.map +1 -1
- package/dist/types/enums.d.ts +2 -0
- package/dist/types/enums.d.ts.map +1 -1
- package/dist/types/enums.js +2 -0
- package/dist/types/enums.js.map +1 -1
- package/dist/types/prompt.d.ts +64 -0
- package/dist/types/prompt.d.ts.map +1 -1
- package/dist/worker/__tests__/bootstrap.test.js +36 -2
- package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
- package/dist/worker/bootstrap.d.ts +7 -0
- package/dist/worker/bootstrap.d.ts.map +1 -1
- package/dist/worker/bootstrap.js +153 -12
- package/dist/worker/bootstrap.js.map +1 -1
- package/instructions/framework.md +7 -0
- package/instructions/guardrails.md +39 -0
- package/instructions/patterns.md +10 -0
- package/package.json +3 -2
package/dist/flows/engine.js
CHANGED
|
@@ -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
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
|
*/
|
|
@@ -59,9 +108,125 @@ export function createFlowEngine(config) {
|
|
|
59
108
|
status: FlowStatus.Created,
|
|
60
109
|
};
|
|
61
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
|
+
}
|
|
62
226
|
/**
|
|
63
227
|
* Run the next step(s) for a flow execution.
|
|
64
228
|
* This is designed to be called by a worker process.
|
|
229
|
+
* The execution must have been claimed by this worker (via claimNext) first.
|
|
65
230
|
*/
|
|
66
231
|
async function runNext(executionId, ctx) {
|
|
67
232
|
const rows = await db
|
|
@@ -87,13 +252,15 @@ export function createFlowEngine(config) {
|
|
|
87
252
|
if (row.status === FlowStatus.Waiting && row.wakeAt && row.wakeAt.getTime() > Date.now()) {
|
|
88
253
|
return { id: row.id, flowName: row.flowName, status: FlowStatus.Waiting };
|
|
89
254
|
}
|
|
90
|
-
// Transition to running
|
|
255
|
+
// Transition to running (only if not already claimed via claimNext)
|
|
91
256
|
if (row.status === FlowStatus.Created || row.status === FlowStatus.Waiting) {
|
|
92
257
|
assertTransition(row.status, FlowStatus.Running);
|
|
93
|
-
await
|
|
258
|
+
await guardedUpdate(executionId, {
|
|
94
259
|
status: FlowStatus.Running,
|
|
95
260
|
waitingForEvent: null,
|
|
96
261
|
wakeAt: null,
|
|
262
|
+
leaseOwner: workerId,
|
|
263
|
+
leaseExpiresAt: new Date(Date.now() + leaseDurationMs),
|
|
97
264
|
});
|
|
98
265
|
}
|
|
99
266
|
// Find current step
|
|
@@ -117,19 +284,121 @@ export function createFlowEngine(config) {
|
|
|
117
284
|
};
|
|
118
285
|
const flowData = config.createDataService ? config.createDataService(flowAuth) : ctx.data;
|
|
119
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);
|
|
120
305
|
const flowCtx = {
|
|
121
306
|
...ctx,
|
|
122
307
|
auth: flowAuth,
|
|
123
308
|
data: flowData,
|
|
124
309
|
events: flowEvents,
|
|
310
|
+
ai: flowAi,
|
|
125
311
|
state: row.state,
|
|
126
312
|
step: currentStepName,
|
|
127
313
|
flowId: executionId,
|
|
314
|
+
workerId,
|
|
315
|
+
signal: stepAc.signal,
|
|
316
|
+
flows: {
|
|
317
|
+
...ctx.flows,
|
|
318
|
+
heartbeat: heartbeatFn,
|
|
319
|
+
},
|
|
128
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);
|
|
129
380
|
// Execute the step
|
|
381
|
+
let result;
|
|
130
382
|
const startedAt = new Date();
|
|
131
|
-
|
|
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
|
+
}
|
|
132
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
|
+
}
|
|
133
402
|
const historyEntry = buildHistoryEntry(currentStepName, result, startedAt, completedAt);
|
|
134
403
|
const history = Array.isArray(row.stepHistory) ? row.stepHistory : [];
|
|
135
404
|
history.push(historyEntry);
|
|
@@ -149,14 +418,23 @@ export function createFlowEngine(config) {
|
|
|
149
418
|
}
|
|
150
419
|
// Wait step — pause the flow
|
|
151
420
|
if (result.waitEvent) {
|
|
152
|
-
await
|
|
421
|
+
const ok = await guardedUpdate(executionId, {
|
|
153
422
|
status: FlowStatus.Waiting,
|
|
154
423
|
state: nextState,
|
|
155
424
|
stepHistory: history,
|
|
156
425
|
currentStep: currentStepName, // stay on current step until resumed
|
|
157
426
|
waitingForEvent: result.waitEvent,
|
|
158
427
|
wakeAt: null,
|
|
428
|
+
leaseOwner: null,
|
|
429
|
+
leaseExpiresAt: null,
|
|
159
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
|
+
}
|
|
160
438
|
return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
|
|
161
439
|
}
|
|
162
440
|
// Delay step — schedule next step after duration
|
|
@@ -169,13 +447,15 @@ export function createFlowEngine(config) {
|
|
|
169
447
|
catch (err) {
|
|
170
448
|
return handleStepFailure(executionId, row, flow, history, err instanceof Error ? err.message : String(err));
|
|
171
449
|
}
|
|
172
|
-
await
|
|
450
|
+
await guardedUpdate(executionId, {
|
|
173
451
|
status: FlowStatus.Waiting,
|
|
174
452
|
state: nextState,
|
|
175
453
|
stepHistory: history,
|
|
176
454
|
currentStep: nextStep,
|
|
177
455
|
waitingForEvent: null,
|
|
178
456
|
wakeAt: new Date(Date.now() + delayMs),
|
|
457
|
+
leaseOwner: null,
|
|
458
|
+
leaseExpiresAt: null,
|
|
179
459
|
});
|
|
180
460
|
return { id: executionId, flowName: row.flowName, status: FlowStatus.Waiting };
|
|
181
461
|
}
|
|
@@ -205,7 +485,7 @@ export function createFlowEngine(config) {
|
|
|
205
485
|
return handleStepFailure(executionId, row, flow, history, 'One or more parallel branches failed');
|
|
206
486
|
}
|
|
207
487
|
const nextStep = getNextStepName(flow.steps, currentStepName);
|
|
208
|
-
await
|
|
488
|
+
await guardedUpdate(executionId, {
|
|
209
489
|
state: parallelState,
|
|
210
490
|
stepHistory: history,
|
|
211
491
|
currentStep: nextStep,
|
|
@@ -218,7 +498,7 @@ export function createFlowEngine(config) {
|
|
|
218
498
|
}
|
|
219
499
|
// Conditional step — jump to the chosen branch
|
|
220
500
|
if (result.nextStep) {
|
|
221
|
-
await
|
|
501
|
+
await guardedUpdate(executionId, {
|
|
222
502
|
state: nextState,
|
|
223
503
|
stepHistory: history,
|
|
224
504
|
currentStep: result.nextStep,
|
|
@@ -228,11 +508,11 @@ export function createFlowEngine(config) {
|
|
|
228
508
|
// Normal completion — advance to next step
|
|
229
509
|
const nextStep = getNextStepName(flow.steps, currentStepName);
|
|
230
510
|
if (!nextStep) {
|
|
231
|
-
await
|
|
511
|
+
await guardedUpdate(executionId, { state: nextState, stepHistory: history });
|
|
232
512
|
await completeFlow(executionId, row.flowName);
|
|
233
513
|
return { id: executionId, flowName: row.flowName, status: FlowStatus.Completed };
|
|
234
514
|
}
|
|
235
|
-
await
|
|
515
|
+
await guardedUpdate(executionId, {
|
|
236
516
|
state: nextState,
|
|
237
517
|
stepHistory: history,
|
|
238
518
|
currentStep: nextStep,
|
|
@@ -257,27 +537,47 @@ export function createFlowEngine(config) {
|
|
|
257
537
|
const flow = registry.get(row.flowName);
|
|
258
538
|
if (!flow)
|
|
259
539
|
throw new Error(`Flow "${row.flowName}" not registered`);
|
|
260
|
-
// Advance past the wait/delay step
|
|
540
|
+
// Advance past the wait/delay step — set to created so claimNext picks it up
|
|
261
541
|
const nextStep = getNextStepName(flow.steps, row.currentStep ?? '');
|
|
262
542
|
await updateExecution(executionId, {
|
|
263
|
-
status: FlowStatus.
|
|
543
|
+
status: FlowStatus.Created,
|
|
264
544
|
currentStep: nextStep,
|
|
265
545
|
state: signal !== undefined ? signal : row.state,
|
|
266
546
|
waitingForEvent: null,
|
|
267
547
|
wakeAt: null,
|
|
548
|
+
leaseOwner: null,
|
|
549
|
+
leaseExpiresAt: null,
|
|
268
550
|
});
|
|
269
551
|
}
|
|
270
552
|
/**
|
|
271
553
|
* Resume all flows waiting on a specific event type.
|
|
554
|
+
* Uses FOR UPDATE SKIP LOCKED to prevent duplicate resumes across workers.
|
|
272
555
|
* Returns the number of executions resumed.
|
|
273
556
|
*/
|
|
274
557
|
async function resumeWaitingByEvent(eventType, signal) {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
}
|
|
281
581
|
}
|
|
282
582
|
return rows.length;
|
|
283
583
|
}
|
|
@@ -313,6 +613,13 @@ export function createFlowEngine(config) {
|
|
|
313
613
|
status: FlowStatus.Cancelled,
|
|
314
614
|
completedAt: new Date(),
|
|
315
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
|
+
}
|
|
316
623
|
if (audit) {
|
|
317
624
|
await audit.record(`flow.cancelled.${row.flowName}`, {
|
|
318
625
|
executionId,
|
|
@@ -340,7 +647,7 @@ export function createFlowEngine(config) {
|
|
|
340
647
|
const maxRetries = flow.retry?.attempts ?? 0;
|
|
341
648
|
if (retryCount <= maxRetries) {
|
|
342
649
|
// Retry: keep current step, increment counter
|
|
343
|
-
await
|
|
650
|
+
await guardedUpdate(executionId, {
|
|
344
651
|
stepHistory: history,
|
|
345
652
|
retryCount,
|
|
346
653
|
lastError: error ?? null,
|
|
@@ -352,21 +659,29 @@ export function createFlowEngine(config) {
|
|
|
352
659
|
return { id: executionId, flowName: row.flowName, status: FlowStatus.Failed };
|
|
353
660
|
}
|
|
354
661
|
async function completeFlow(executionId, flowName) {
|
|
355
|
-
await
|
|
662
|
+
const ok = await guardedUpdate(executionId, {
|
|
356
663
|
status: FlowStatus.Completed,
|
|
357
664
|
currentStep: null,
|
|
358
665
|
completedAt: new Date(),
|
|
666
|
+
leaseOwner: null,
|
|
667
|
+
leaseExpiresAt: null,
|
|
359
668
|
});
|
|
669
|
+
if (!ok)
|
|
670
|
+
return; // Lease lost — another worker owns this execution
|
|
360
671
|
if (audit) {
|
|
361
672
|
await audit.record(`flow.completed.${flowName}`, { executionId, flowName });
|
|
362
673
|
}
|
|
363
674
|
}
|
|
364
675
|
async function failFlow(executionId, flowName, error, row) {
|
|
365
|
-
await
|
|
676
|
+
const ok = await guardedUpdate(executionId, {
|
|
366
677
|
status: FlowStatus.Failed,
|
|
367
678
|
lastError: error ?? 'Unknown error',
|
|
368
679
|
completedAt: new Date(),
|
|
680
|
+
leaseOwner: null,
|
|
681
|
+
leaseExpiresAt: null,
|
|
369
682
|
});
|
|
683
|
+
if (!ok)
|
|
684
|
+
return; // Lease lost — another worker owns this execution
|
|
370
685
|
if (audit) {
|
|
371
686
|
await audit.record(`flow.failed.${flowName}`, {
|
|
372
687
|
executionId,
|
|
@@ -397,7 +712,96 @@ export function createFlowEngine(config) {
|
|
|
397
712
|
.set({ ...updates, updatedAt: new Date() })
|
|
398
713
|
.where(eq(flowExecutionsTable.id, id));
|
|
399
714
|
}
|
|
400
|
-
|
|
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
|
+
};
|
|
401
805
|
}
|
|
402
806
|
function mergeExecutionState(currentState, stepData) {
|
|
403
807
|
if (stepData === undefined) {
|