@rivet-dev/vercel-world 2.3.6
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/.turbo/turbo-build.log +4 -0
- package/README.md +31 -0
- package/dist/actors/coordinator.d.ts +114 -0
- package/dist/actors/coordinator.d.ts.map +1 -0
- package/dist/actors/coordinator.js +232 -0
- package/dist/actors/coordinator.js.map +1 -0
- package/dist/actors/db.d.ts +8 -0
- package/dist/actors/db.d.ts.map +1 -0
- package/dist/actors/db.js +282 -0
- package/dist/actors/db.js.map +1 -0
- package/dist/actors/dispatcher.d.ts +84 -0
- package/dist/actors/dispatcher.d.ts.map +1 -0
- package/dist/actors/dispatcher.js +498 -0
- package/dist/actors/dispatcher.js.map +1 -0
- package/dist/actors/hook-token.d.ts +26 -0
- package/dist/actors/hook-token.d.ts.map +1 -0
- package/dist/actors/hook-token.js +151 -0
- package/dist/actors/hook-token.js.map +1 -0
- package/dist/actors/shared.d.ts +366 -0
- package/dist/actors/shared.d.ts.map +1 -0
- package/dist/actors/shared.js +112 -0
- package/dist/actors/shared.js.map +1 -0
- package/dist/actors/streams.d.ts +26 -0
- package/dist/actors/streams.d.ts.map +1 -0
- package/dist/actors/streams.js +127 -0
- package/dist/actors/streams.js.map +1 -0
- package/dist/actors/workflow-run.d.ts +890 -0
- package/dist/actors/workflow-run.d.ts.map +1 -0
- package/dist/actors/workflow-run.js +863 -0
- package/dist/actors/workflow-run.js.map +1 -0
- package/dist/actors.d.ts +2204 -0
- package/dist/actors.d.ts.map +1 -0
- package/dist/actors.js +16 -0
- package/dist/actors.js.map +1 -0
- package/dist/codec.d.ts +4 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +27 -0
- package/dist/codec.js.map +1 -0
- package/dist/index.d.ts +843 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +553 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +24 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +51 -0
- package/scripts/conformance/run.ts +278 -0
- package/scripts/integration/run.ts +935 -0
- package/src/actors/coordinator.ts +360 -0
- package/src/actors/db.ts +291 -0
- package/src/actors/dispatcher.ts +787 -0
- package/src/actors/hook-token.ts +239 -0
- package/src/actors/shared.ts +153 -0
- package/src/actors/streams.ts +215 -0
- package/src/actors/workflow-run.ts +1466 -0
- package/src/actors.ts +18 -0
- package/src/codec.ts +28 -0
- package/src/index.ts +768 -0
- package/src/runtime.ts +29 -0
- package/tests/conformance.test.ts +8 -0
- package/tests/helpers/db.ts +62 -0
- package/tests/helpers/dispatcher-driver.ts +71 -0
- package/tests/helpers/harness.ts +161 -0
- package/tests/integration/crash-restart.test.ts +145 -0
- package/tests/integration/dispatcher-loop.test.ts +144 -0
- package/tests/integration/hook-token.test.ts +160 -0
- package/tests/integration/hooks.test.ts +123 -0
- package/tests/integration/streams.test.ts +178 -0
- package/tests/integration/workflow-events.test.ts +326 -0
- package/tests/setup.ts +10 -0
- package/tests/unit/codec.test.ts +73 -0
- package/tests/unit/coordinator-record.test.ts +177 -0
- package/tests/unit/db-migrations.test.ts +65 -0
- package/tests/unit/dispatcher-queue.test.ts +274 -0
- package/tests/unit/logging.test.ts +49 -0
- package/tests/unit/readiness.test.ts +102 -0
- package/tests/unit/transaction.test.ts +76 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EntityConflictError,
|
|
3
|
+
HookNotFoundError,
|
|
4
|
+
TooEarlyError,
|
|
5
|
+
WorkflowRunNotFoundError,
|
|
6
|
+
WorkflowWorldError,
|
|
7
|
+
} from "@workflow/errors";
|
|
8
|
+
import {
|
|
9
|
+
MessageId,
|
|
10
|
+
SPEC_VERSION_CURRENT,
|
|
11
|
+
stripEventDataRefs,
|
|
12
|
+
type CreateEventParams,
|
|
13
|
+
type CreateEventRequest,
|
|
14
|
+
type Event,
|
|
15
|
+
type EventResult,
|
|
16
|
+
type GetEventParams,
|
|
17
|
+
type GetHookParams,
|
|
18
|
+
type GetStepParams,
|
|
19
|
+
type GetWorkflowRunParams,
|
|
20
|
+
type Hook,
|
|
21
|
+
type ListEventsByCorrelationIdParams,
|
|
22
|
+
type ListEventsParams,
|
|
23
|
+
type ListHooksParams,
|
|
24
|
+
type ListWorkflowRunStepsParams,
|
|
25
|
+
type RunCreatedEventRequest,
|
|
26
|
+
type Step,
|
|
27
|
+
type Wait,
|
|
28
|
+
type WorkflowRun,
|
|
29
|
+
} from "@workflow/world";
|
|
30
|
+
import { actor } from "rivetkit";
|
|
31
|
+
import { isDeepStrictEqual } from "node:util";
|
|
32
|
+
import { monotonicFactory } from "ulid";
|
|
33
|
+
import { decodeValue, encodeValue } from "../codec.js";
|
|
34
|
+
import type { CoordinatorIndexUpdate } from "./coordinator.js";
|
|
35
|
+
import { workflowDb } from "./db.js";
|
|
36
|
+
import {
|
|
37
|
+
createDispatcherVars,
|
|
38
|
+
enqueueDispatch,
|
|
39
|
+
forceStaleInflightForTesting,
|
|
40
|
+
insertDispatch,
|
|
41
|
+
inspectQueue,
|
|
42
|
+
nextInflightRecoveryAt,
|
|
43
|
+
startDispatchLoop,
|
|
44
|
+
type DispatchContext,
|
|
45
|
+
} from "./dispatcher.js";
|
|
46
|
+
import {
|
|
47
|
+
expireClosedStreamForTesting,
|
|
48
|
+
cleanupExpiredStreams,
|
|
49
|
+
getStreamChunks,
|
|
50
|
+
getStreamInfo,
|
|
51
|
+
listStreams,
|
|
52
|
+
STREAM_TTL_MS,
|
|
53
|
+
streamAppended,
|
|
54
|
+
writeStream,
|
|
55
|
+
} from "./streams.js";
|
|
56
|
+
import {
|
|
57
|
+
filterData,
|
|
58
|
+
filterHookData,
|
|
59
|
+
one,
|
|
60
|
+
requireRun,
|
|
61
|
+
rowToEvent,
|
|
62
|
+
rowToHook,
|
|
63
|
+
rowToStep,
|
|
64
|
+
rowToWait,
|
|
65
|
+
toMs,
|
|
66
|
+
withActorTransaction,
|
|
67
|
+
type Ctx,
|
|
68
|
+
} from "./shared.js";
|
|
69
|
+
|
|
70
|
+
const ulid = monotonicFactory();
|
|
71
|
+
const terminalRuns = new Set(["completed", "failed", "cancelled"]);
|
|
72
|
+
const terminalSteps = new Set(["completed", "failed", "cancelled"]);
|
|
73
|
+
const creationEvents = new Set([
|
|
74
|
+
"run_created",
|
|
75
|
+
"step_created",
|
|
76
|
+
"hook_created",
|
|
77
|
+
"wait_created",
|
|
78
|
+
]);
|
|
79
|
+
type InitialDispatchConfig = {
|
|
80
|
+
runtimeUrl: string;
|
|
81
|
+
queueName: string;
|
|
82
|
+
headers: Record<string, string>;
|
|
83
|
+
initial: {
|
|
84
|
+
messageId: string;
|
|
85
|
+
body: string;
|
|
86
|
+
idempotencyKey: string;
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
type HookSideEffects = {
|
|
91
|
+
confirm?: { token: string; hookId: string; generation: number };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type PendingOperation =
|
|
95
|
+
| CoordinatorIndexUpdate
|
|
96
|
+
| ({ type: "hook.confirm" } & NonNullable<HookSideEffects["confirm"]> & {
|
|
97
|
+
runId: string;
|
|
98
|
+
})
|
|
99
|
+
| {
|
|
100
|
+
type: "hook.release";
|
|
101
|
+
token: string;
|
|
102
|
+
hookId: string;
|
|
103
|
+
generation: number;
|
|
104
|
+
runId: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type HookConflictEventRequest = {
|
|
108
|
+
eventType: "hook_conflict";
|
|
109
|
+
specVersion?: number;
|
|
110
|
+
correlationId: string;
|
|
111
|
+
eventData: {
|
|
112
|
+
token: string;
|
|
113
|
+
conflictingRunId?: string;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type StorableEventRequest =
|
|
118
|
+
| CreateEventRequest
|
|
119
|
+
| RunCreatedEventRequest
|
|
120
|
+
| HookConflictEventRequest;
|
|
121
|
+
|
|
122
|
+
type WorkflowVars = DispatchContext["vars"] & {
|
|
123
|
+
recoveryPrearmPromise: Promise<void> | null;
|
|
124
|
+
recoveryDeletePromise: Promise<void> | null;
|
|
125
|
+
recoveryWriters: number;
|
|
126
|
+
pendingDraining: boolean;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type WorkflowContext = Omit<DispatchContext, "vars"> & {
|
|
130
|
+
vars: WorkflowVars;
|
|
131
|
+
key: readonly string[];
|
|
132
|
+
client<T = any>(): T;
|
|
133
|
+
broadcast(name: "streamAppended", value: unknown): void;
|
|
134
|
+
cron: {
|
|
135
|
+
every(options: {
|
|
136
|
+
name: string;
|
|
137
|
+
interval: number;
|
|
138
|
+
action: string;
|
|
139
|
+
args?: unknown[];
|
|
140
|
+
maxHistory?: number;
|
|
141
|
+
}): Promise<void>;
|
|
142
|
+
delete(name: string): Promise<boolean>;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const RECOVERY_CRON = "vercel-world-recovery";
|
|
147
|
+
const RECOVERY_INTERVAL_MS = 5_000;
|
|
148
|
+
|
|
149
|
+
async function prearmRecovery(c: WorkflowContext) {
|
|
150
|
+
// Recurring schedules advance before dispatch. If a fire is consumed while
|
|
151
|
+
// this action is committing, the next recovery fire remains durable.
|
|
152
|
+
if (c.vars.recoveryDeletePromise) {
|
|
153
|
+
await c.vars.recoveryDeletePromise;
|
|
154
|
+
}
|
|
155
|
+
if (!c.vars.recoveryPrearmPromise) {
|
|
156
|
+
const arm = c.cron.every({
|
|
157
|
+
name: RECOVERY_CRON,
|
|
158
|
+
interval: RECOVERY_INTERVAL_MS,
|
|
159
|
+
action: "wake",
|
|
160
|
+
args: [],
|
|
161
|
+
maxHistory: 0,
|
|
162
|
+
});
|
|
163
|
+
c.vars.recoveryPrearmPromise = arm;
|
|
164
|
+
void arm.catch(() => {
|
|
165
|
+
if (c.vars.recoveryPrearmPromise === arm) {
|
|
166
|
+
c.vars.recoveryPrearmPromise = null;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
await c.vars.recoveryPrearmPromise;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function finishRecoveryArm(c: WorkflowContext) {
|
|
174
|
+
const nextWakeAt = await armNextWake(c);
|
|
175
|
+
// An immediately-due one-shot may already be claimed and waiting behind this
|
|
176
|
+
// action, so keep the recurring backstop until a later wake drains that work.
|
|
177
|
+
// A wake safely beyond the next backstop interval cannot be claimed during
|
|
178
|
+
// this action; the exact durable alarm is sufficient until then.
|
|
179
|
+
if (
|
|
180
|
+
c.vars.recoveryWriters === 0 &&
|
|
181
|
+
!c.vars.recoveryDeletePromise &&
|
|
182
|
+
(nextWakeAt == null || nextWakeAt > Date.now() + RECOVERY_INTERVAL_MS)
|
|
183
|
+
) {
|
|
184
|
+
const deletion = c.cron.delete(RECOVERY_CRON).then(() => undefined);
|
|
185
|
+
const tracked = deletion.finally(() => {
|
|
186
|
+
// A failed delete is ambiguous: force the next writer to re-arm after the
|
|
187
|
+
// delete settles instead of assuming the named cron still exists.
|
|
188
|
+
c.vars.recoveryPrearmPromise = null;
|
|
189
|
+
if (c.vars.recoveryDeletePromise === tracked) {
|
|
190
|
+
c.vars.recoveryDeletePromise = null;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
c.vars.recoveryDeletePromise = tracked;
|
|
194
|
+
await tracked;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const createWorkflowVars = (): WorkflowVars => ({
|
|
199
|
+
...createDispatcherVars(),
|
|
200
|
+
recoveryPrearmPromise: null,
|
|
201
|
+
recoveryDeletePromise: null,
|
|
202
|
+
recoveryWriters: 0,
|
|
203
|
+
pendingDraining: false,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function asWorkflowContext(c: unknown) {
|
|
207
|
+
const ctx = c as WorkflowContext;
|
|
208
|
+
ctx.armNextWake = async () => {
|
|
209
|
+
await armNextWake(ctx);
|
|
210
|
+
};
|
|
211
|
+
return ctx;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function withSerializedAction<T>(
|
|
215
|
+
c: WorkflowContext,
|
|
216
|
+
fn: () => Promise<T>,
|
|
217
|
+
): Promise<T> {
|
|
218
|
+
const previous = c.vars.serialTail ?? Promise.resolve();
|
|
219
|
+
let release!: () => void;
|
|
220
|
+
const current = new Promise<void>((resolve) => {
|
|
221
|
+
release = resolve;
|
|
222
|
+
});
|
|
223
|
+
c.vars.serialTail = previous.then(() => current);
|
|
224
|
+
await previous;
|
|
225
|
+
try {
|
|
226
|
+
return await fn();
|
|
227
|
+
} finally {
|
|
228
|
+
release();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function withRecoveryWriter<T>(
|
|
233
|
+
c: WorkflowContext,
|
|
234
|
+
fn: () => Promise<T>,
|
|
235
|
+
): Promise<T> {
|
|
236
|
+
c.vars.recoveryWriters++;
|
|
237
|
+
try {
|
|
238
|
+
await prearmRecovery(c);
|
|
239
|
+
return await fn();
|
|
240
|
+
} finally {
|
|
241
|
+
try {
|
|
242
|
+
await armNextWake(c);
|
|
243
|
+
} catch {
|
|
244
|
+
// The recurring recovery cron remains the durable fallback.
|
|
245
|
+
} finally {
|
|
246
|
+
c.vars.recoveryWriters--;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function assertRunKey(c: { key: readonly string[] }, runId: string) {
|
|
252
|
+
const actorRunId = c.key[0];
|
|
253
|
+
if (!actorRunId || actorRunId !== runId) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`workflowRun actor key mismatch: expected ${actorRunId ?? "<missing>"}, received ${runId}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function errorDetails(error: unknown): { message: string; stack?: string } {
|
|
261
|
+
if (typeof error === "string") return { message: error };
|
|
262
|
+
if (error instanceof Error) {
|
|
263
|
+
return { message: error.message, stack: error.stack };
|
|
264
|
+
}
|
|
265
|
+
return { message: "Unknown error" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function currentRunStatus(c: Ctx, runId: string) {
|
|
269
|
+
const row = await one(
|
|
270
|
+
c,
|
|
271
|
+
"SELECT status FROM workflow_runs WHERE run_id = ?",
|
|
272
|
+
runId,
|
|
273
|
+
);
|
|
274
|
+
return row?.status == null ? null : String(row.status);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function insertEvent(
|
|
278
|
+
c: Ctx,
|
|
279
|
+
runId: string,
|
|
280
|
+
eventId: string,
|
|
281
|
+
data: StorableEventRequest,
|
|
282
|
+
specVersion: number,
|
|
283
|
+
now: number,
|
|
284
|
+
) {
|
|
285
|
+
// Preserve eventData for every event that carries it — including run_started,
|
|
286
|
+
// whose optional creation fields back the resilient-start recovery path
|
|
287
|
+
// (recreate the run from run_started when run_created was lost; SPEC §3.2,
|
|
288
|
+
// `@workflow/world` queue.d.ts).
|
|
289
|
+
const storedEventData = "eventData" in data ? data.eventData : undefined;
|
|
290
|
+
const dedupeCreation = creationEvents.has(data.eventType);
|
|
291
|
+
await c.db.execute(
|
|
292
|
+
`
|
|
293
|
+
INSERT ${dedupeCreation ? "OR IGNORE" : ""} INTO workflow_events (
|
|
294
|
+
event_id, run_id, event_type, correlation_id, event_data, spec_version, created_at
|
|
295
|
+
)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
297
|
+
`,
|
|
298
|
+
eventId,
|
|
299
|
+
runId,
|
|
300
|
+
data.eventType,
|
|
301
|
+
"correlationId" in data ? data.correlationId : null,
|
|
302
|
+
encodeValue(storedEventData),
|
|
303
|
+
specVersion,
|
|
304
|
+
now,
|
|
305
|
+
);
|
|
306
|
+
const inserted = await one(
|
|
307
|
+
c,
|
|
308
|
+
"SELECT * FROM workflow_events WHERE event_id = ?",
|
|
309
|
+
eventId,
|
|
310
|
+
);
|
|
311
|
+
if (inserted) return rowToEvent(inserted);
|
|
312
|
+
if (dedupeCreation) {
|
|
313
|
+
const existing =
|
|
314
|
+
data.eventType === "run_created"
|
|
315
|
+
? await one(
|
|
316
|
+
c,
|
|
317
|
+
"SELECT * FROM workflow_events WHERE run_id = ? AND event_type = 'run_created'",
|
|
318
|
+
runId,
|
|
319
|
+
)
|
|
320
|
+
: await one(
|
|
321
|
+
c,
|
|
322
|
+
"SELECT * FROM workflow_events WHERE run_id = ? AND event_type = ? AND correlation_id = ?",
|
|
323
|
+
runId,
|
|
324
|
+
data.eventType,
|
|
325
|
+
"correlationId" in data ? data.correlationId : null,
|
|
326
|
+
);
|
|
327
|
+
if (existing) return rowToEvent(existing);
|
|
328
|
+
}
|
|
329
|
+
return rowToEvent({
|
|
330
|
+
...data,
|
|
331
|
+
run_id: runId,
|
|
332
|
+
event_id: eventId,
|
|
333
|
+
event_type: data.eventType,
|
|
334
|
+
correlation_id: "correlationId" in data ? data.correlationId : null,
|
|
335
|
+
event_data: encodeValue(storedEventData),
|
|
336
|
+
spec_version: specVersion,
|
|
337
|
+
created_at: now,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function listEventsForRun(c: Ctx, runId: string) {
|
|
342
|
+
return (
|
|
343
|
+
await c.db.execute(
|
|
344
|
+
"SELECT * FROM workflow_events WHERE run_id = ? ORDER BY event_id",
|
|
345
|
+
runId,
|
|
346
|
+
)
|
|
347
|
+
).map(rowToEvent);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function releaseRunHooks(c: Ctx, runId: string) {
|
|
351
|
+
await c.db.execute("DELETE FROM workflow_hooks WHERE run_id = ?", runId);
|
|
352
|
+
await c.db.execute("DELETE FROM workflow_waits WHERE run_id = ?", runId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const OUTBOX_CLAIM_MS = 60_000;
|
|
356
|
+
const OUTBOX_BATCH = 32;
|
|
357
|
+
const OUTBOX_DRAIN_BUDGET_MS = 5_000;
|
|
358
|
+
|
|
359
|
+
async function withinDeadline<T>(promise: Promise<T>, deadline: number): Promise<T> {
|
|
360
|
+
const remaining = deadline - Date.now();
|
|
361
|
+
if (remaining <= 0) throw new Error("pending operation drain exceeded its time budget");
|
|
362
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
try {
|
|
364
|
+
return await Promise.race([
|
|
365
|
+
promise,
|
|
366
|
+
new Promise<never>((_, reject) => {
|
|
367
|
+
timeout = setTimeout(
|
|
368
|
+
() => reject(new Error("pending operation drain exceeded its time budget")),
|
|
369
|
+
remaining,
|
|
370
|
+
);
|
|
371
|
+
}),
|
|
372
|
+
]);
|
|
373
|
+
} finally {
|
|
374
|
+
if (timeout) clearTimeout(timeout);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function armNextWake(c: WorkflowContext) {
|
|
379
|
+
const [dispatchReady, dispatchInflight, pending, streamExpiry] = await Promise.all([
|
|
380
|
+
one(c, "SELECT MIN(next_at) AS due FROM dispatcher_queue WHERE status = 'ready'"),
|
|
381
|
+
nextInflightRecoveryAt(c, c.vars.activeMessageIds).then((due) =>
|
|
382
|
+
due == null ? undefined : { due },
|
|
383
|
+
),
|
|
384
|
+
one(c, "SELECT MIN(next_at) AS due FROM pending_ops"),
|
|
385
|
+
STREAM_TTL_MS === 0
|
|
386
|
+
? Promise.resolve(undefined)
|
|
387
|
+
: one(c, `SELECT MIN(created_at + ?) AS due FROM stream_chunks
|
|
388
|
+
WHERE eof = 1`, STREAM_TTL_MS),
|
|
389
|
+
]);
|
|
390
|
+
const due = [
|
|
391
|
+
dispatchReady?.due,
|
|
392
|
+
dispatchInflight?.due,
|
|
393
|
+
pending?.due,
|
|
394
|
+
streamExpiry?.due,
|
|
395
|
+
]
|
|
396
|
+
.filter((value): value is number | string => value != null)
|
|
397
|
+
.map(Number)
|
|
398
|
+
.filter(Number.isFinite);
|
|
399
|
+
if (due.length === 0) return null;
|
|
400
|
+
const target = Math.min(...due);
|
|
401
|
+
if (c.vars.wakeArmedAt != null && c.vars.wakeArmedAt <= target) {
|
|
402
|
+
return c.vars.wakeArmedAt;
|
|
403
|
+
}
|
|
404
|
+
await c.schedule.at(Math.max(Date.now(), target), "wake");
|
|
405
|
+
c.vars.wakeArmedAt = target;
|
|
406
|
+
return target;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function addPendingOp(
|
|
410
|
+
c: Ctx,
|
|
411
|
+
opId: string,
|
|
412
|
+
runRevision: number,
|
|
413
|
+
payload: PendingOperation,
|
|
414
|
+
) {
|
|
415
|
+
const now = Date.now();
|
|
416
|
+
await c.db.execute(
|
|
417
|
+
`INSERT INTO pending_ops (
|
|
418
|
+
op_id, op_type, payload, operation_revision, status, next_at,
|
|
419
|
+
attempt, created_at, updated_at
|
|
420
|
+
) VALUES (?, ?, ?, ?, 'ready', ?, 0, ?, ?)
|
|
421
|
+
ON CONFLICT(op_id) DO UPDATE SET
|
|
422
|
+
op_type = excluded.op_type,
|
|
423
|
+
payload = excluded.payload,
|
|
424
|
+
operation_revision = excluded.operation_revision,
|
|
425
|
+
status = 'ready',
|
|
426
|
+
claim_id = NULL,
|
|
427
|
+
next_at = excluded.next_at,
|
|
428
|
+
updated_at = excluded.updated_at
|
|
429
|
+
WHERE excluded.operation_revision > pending_ops.operation_revision`,
|
|
430
|
+
opId,
|
|
431
|
+
payload.type,
|
|
432
|
+
encodeValue(payload),
|
|
433
|
+
runRevision,
|
|
434
|
+
now,
|
|
435
|
+
now,
|
|
436
|
+
now,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function drainPendingOps(c: WorkflowContext) {
|
|
441
|
+
const deadline = Date.now() + OUTBOX_DRAIN_BUDGET_MS;
|
|
442
|
+
for (let processed = 0; processed < OUTBOX_BATCH; processed++) {
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
const claimId = `claim_${ulid()}`;
|
|
445
|
+
const rows = await c.db.execute(
|
|
446
|
+
`UPDATE pending_ops SET status = 'inflight', claim_id = ?,
|
|
447
|
+
next_at = ?, attempt = attempt + 1, updated_at = ?
|
|
448
|
+
WHERE op_id = (
|
|
449
|
+
SELECT op_id FROM pending_ops
|
|
450
|
+
WHERE (status = 'ready' AND next_at <= ?)
|
|
451
|
+
OR (status = 'inflight' AND next_at <= ?)
|
|
452
|
+
ORDER BY created_at ASC LIMIT 1
|
|
453
|
+
) RETURNING op_id, payload, operation_revision, attempt`,
|
|
454
|
+
claimId,
|
|
455
|
+
now + OUTBOX_CLAIM_MS,
|
|
456
|
+
now,
|
|
457
|
+
now,
|
|
458
|
+
now,
|
|
459
|
+
);
|
|
460
|
+
const row = rows[0];
|
|
461
|
+
if (!row) return;
|
|
462
|
+
await armNextWake(c);
|
|
463
|
+
try {
|
|
464
|
+
const payload = decodeValue<PendingOperation>(row.payload);
|
|
465
|
+
if (!payload) throw new Error("pending coordinator update is empty");
|
|
466
|
+
if (payload.type === "hook.confirm") {
|
|
467
|
+
const result = await withinDeadline<{ ok: boolean }>(
|
|
468
|
+
c.client<any>().hookToken.getOrCreate([payload.token]).confirm(
|
|
469
|
+
payload.token,
|
|
470
|
+
payload.generation,
|
|
471
|
+
payload.runId,
|
|
472
|
+
payload.hookId,
|
|
473
|
+
),
|
|
474
|
+
deadline,
|
|
475
|
+
);
|
|
476
|
+
if (!result.ok) throw new Error("hook confirmation generation is stale");
|
|
477
|
+
} else if (payload.type === "hook.release") {
|
|
478
|
+
await withinDeadline(
|
|
479
|
+
c.client<any>().hookToken.getOrCreate([payload.token]).release(
|
|
480
|
+
payload.token,
|
|
481
|
+
payload.generation,
|
|
482
|
+
payload.runId,
|
|
483
|
+
payload.hookId,
|
|
484
|
+
),
|
|
485
|
+
deadline,
|
|
486
|
+
);
|
|
487
|
+
} else {
|
|
488
|
+
await withinDeadline(
|
|
489
|
+
c.client<any>().coordinator.getOrCreate(["coordinator"]).update(payload),
|
|
490
|
+
deadline,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
await c.db.execute(
|
|
494
|
+
`DELETE FROM pending_ops
|
|
495
|
+
WHERE op_id = ? AND claim_id = ? AND operation_revision = ?`,
|
|
496
|
+
String(row.op_id),
|
|
497
|
+
claimId,
|
|
498
|
+
Number(row.operation_revision),
|
|
499
|
+
);
|
|
500
|
+
} catch {
|
|
501
|
+
await c.db.execute(
|
|
502
|
+
`UPDATE pending_ops SET status = 'ready', claim_id = NULL,
|
|
503
|
+
next_at = ?, updated_at = ? WHERE op_id = ? AND claim_id = ?`,
|
|
504
|
+
now + Math.min(30_000, 1_000 * 2 ** Math.min(Number(row.attempt) - 1, 5)),
|
|
505
|
+
now,
|
|
506
|
+
String(row.op_id),
|
|
507
|
+
claimId,
|
|
508
|
+
);
|
|
509
|
+
await armNextWake(c);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// A full batch means there may be more work. Yield through the actor alarm.
|
|
514
|
+
await c.schedule.after(0, "wake");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function startPendingDrain(c: WorkflowContext) {
|
|
518
|
+
if (c.vars.pendingDraining) return;
|
|
519
|
+
c.vars.pendingDraining = true;
|
|
520
|
+
const drain = drainPendingOps(c).finally(() => {
|
|
521
|
+
c.vars.pendingDraining = false;
|
|
522
|
+
});
|
|
523
|
+
void c.keepAwake(drain).catch(() => {});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export const workflowRun = actor({
|
|
527
|
+
db: workflowDb,
|
|
528
|
+
events: { streamAppended },
|
|
529
|
+
createVars: createWorkflowVars,
|
|
530
|
+
onWake: async (c) => {
|
|
531
|
+
const ctx = asWorkflowContext(c);
|
|
532
|
+
await withSerializedAction(ctx, async () => {
|
|
533
|
+
await prearmRecovery(ctx);
|
|
534
|
+
ctx.vars.wakeArmedAt = null;
|
|
535
|
+
await cleanupExpiredStreams(ctx);
|
|
536
|
+
await drainPendingOps(ctx);
|
|
537
|
+
startDispatchLoop(ctx, ctx.key[0] ?? "__health");
|
|
538
|
+
await finishRecoveryArm(ctx);
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
actions: {
|
|
542
|
+
appendEvent: async (
|
|
543
|
+
c,
|
|
544
|
+
runIdOrNull: string | null,
|
|
545
|
+
data: StorableEventRequest,
|
|
546
|
+
params?: CreateEventParams,
|
|
547
|
+
initialDispatch?: InitialDispatchConfig,
|
|
548
|
+
hookSideEffects?: HookSideEffects,
|
|
549
|
+
): Promise<EventResult> => {
|
|
550
|
+
const wfc = asWorkflowContext(c);
|
|
551
|
+
return withRecoveryWriter(wfc, async () => {
|
|
552
|
+
const actorRunId = wfc.key[0];
|
|
553
|
+
if (!actorRunId) throw new Error("workflowRun actor key is missing");
|
|
554
|
+
if (runIdOrNull != null) assertRunKey(wfc, runIdOrNull);
|
|
555
|
+
const result = await withActorTransaction(wfc, async (c): Promise<EventResult> => {
|
|
556
|
+
const runId =
|
|
557
|
+
data.eventType === "run_created" && !runIdOrNull
|
|
558
|
+
? actorRunId
|
|
559
|
+
: runIdOrNull;
|
|
560
|
+
if (!runId) throw new Error("runId is required");
|
|
561
|
+
|
|
562
|
+
const specVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
|
|
563
|
+
const now = Date.now();
|
|
564
|
+
const resolveData = params?.resolveData ?? "all";
|
|
565
|
+
let run: WorkflowRun | undefined;
|
|
566
|
+
let step: Step | undefined;
|
|
567
|
+
let hook: Hook | undefined;
|
|
568
|
+
let hooksToDelete: Array<{ hook: Hook; generation: number | null }> = [];
|
|
569
|
+
let wait: Wait | undefined;
|
|
570
|
+
let stepCreated: boolean | undefined;
|
|
571
|
+
|
|
572
|
+
const status =
|
|
573
|
+
data.eventType === "run_created"
|
|
574
|
+
? null
|
|
575
|
+
: await currentRunStatus(c, runId);
|
|
576
|
+
if (status && terminalRuns.has(status)) {
|
|
577
|
+
throw new EntityConflictError(
|
|
578
|
+
`Cannot modify run in terminal state "${status}"`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (
|
|
582
|
+
status == null &&
|
|
583
|
+
data.eventType !== "run_created" &&
|
|
584
|
+
data.eventType !== "run_started"
|
|
585
|
+
) {
|
|
586
|
+
throw new WorkflowRunNotFoundError(runId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (data.eventType === "run_created") {
|
|
590
|
+
await c.db.execute(
|
|
591
|
+
`
|
|
592
|
+
INSERT OR IGNORE INTO workflow_runs (
|
|
593
|
+
run_id, status, deployment_id, workflow_name, spec_version,
|
|
594
|
+
execution_context, attributes, input, created_at, updated_at
|
|
595
|
+
)
|
|
596
|
+
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)
|
|
597
|
+
`,
|
|
598
|
+
runId,
|
|
599
|
+
data.eventData.deploymentId,
|
|
600
|
+
data.eventData.workflowName,
|
|
601
|
+
specVersion,
|
|
602
|
+
encodeValue(data.eventData.executionContext),
|
|
603
|
+
encodeValue(data.eventData.attributes ?? {}),
|
|
604
|
+
encodeValue(data.eventData.input),
|
|
605
|
+
now,
|
|
606
|
+
now,
|
|
607
|
+
);
|
|
608
|
+
run = await requireRun(c, runId);
|
|
609
|
+
if (initialDispatch) {
|
|
610
|
+
await insertDispatch(
|
|
611
|
+
c,
|
|
612
|
+
initialDispatch.initial.messageId,
|
|
613
|
+
initialDispatch.queueName,
|
|
614
|
+
initialDispatch.runtimeUrl,
|
|
615
|
+
initialDispatch.initial.body,
|
|
616
|
+
initialDispatch.headers,
|
|
617
|
+
0,
|
|
618
|
+
initialDispatch.initial.idempotencyKey,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (data.eventType === "run_started") {
|
|
624
|
+
// Resilient start (SPEC §3.2): if run_created was lost, recreate the
|
|
625
|
+
// run from the creation fields carried on run_started when present.
|
|
626
|
+
const startData = data.eventData;
|
|
627
|
+
if (startData?.deploymentId && startData.workflowName) {
|
|
628
|
+
await c.db.execute(
|
|
629
|
+
`
|
|
630
|
+
INSERT OR IGNORE INTO workflow_runs (
|
|
631
|
+
run_id, status, deployment_id, workflow_name, spec_version,
|
|
632
|
+
execution_context, attributes, input, created_at, updated_at
|
|
633
|
+
)
|
|
634
|
+
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)
|
|
635
|
+
`,
|
|
636
|
+
runId,
|
|
637
|
+
startData.deploymentId,
|
|
638
|
+
startData.workflowName,
|
|
639
|
+
specVersion,
|
|
640
|
+
encodeValue(startData.executionContext),
|
|
641
|
+
encodeValue(startData.attributes ?? {}),
|
|
642
|
+
encodeValue(startData.input),
|
|
643
|
+
now,
|
|
644
|
+
now,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
await c.db.execute(
|
|
648
|
+
`
|
|
649
|
+
UPDATE workflow_runs
|
|
650
|
+
SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ?
|
|
651
|
+
WHERE run_id = ?
|
|
652
|
+
`,
|
|
653
|
+
now,
|
|
654
|
+
now,
|
|
655
|
+
runId,
|
|
656
|
+
);
|
|
657
|
+
run = await requireRun(c, runId);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (data.eventType === "run_completed") {
|
|
661
|
+
hooksToDelete = (
|
|
662
|
+
await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)
|
|
663
|
+
).map((row) => ({
|
|
664
|
+
hook: rowToHook(row),
|
|
665
|
+
generation:
|
|
666
|
+
row.token_generation == null ? null : Number(row.token_generation),
|
|
667
|
+
}));
|
|
668
|
+
await c.db.execute(
|
|
669
|
+
`
|
|
670
|
+
UPDATE workflow_runs
|
|
671
|
+
SET status = 'completed', output = ?, completed_at = ?, updated_at = ?
|
|
672
|
+
WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
673
|
+
`,
|
|
674
|
+
encodeValue(data.eventData.output),
|
|
675
|
+
now,
|
|
676
|
+
now,
|
|
677
|
+
runId,
|
|
678
|
+
);
|
|
679
|
+
run = await requireRun(c, runId);
|
|
680
|
+
await releaseRunHooks(c, runId);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (data.eventType === "run_failed") {
|
|
684
|
+
hooksToDelete = (
|
|
685
|
+
await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)
|
|
686
|
+
).map((row) => ({
|
|
687
|
+
hook: rowToHook(row),
|
|
688
|
+
generation:
|
|
689
|
+
row.token_generation == null ? null : Number(row.token_generation),
|
|
690
|
+
}));
|
|
691
|
+
const error = errorDetails(data.eventData.error);
|
|
692
|
+
await c.db.execute(
|
|
693
|
+
`
|
|
694
|
+
UPDATE workflow_runs
|
|
695
|
+
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
696
|
+
WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
697
|
+
`,
|
|
698
|
+
encodeValue({
|
|
699
|
+
message: error.message,
|
|
700
|
+
stack: error.stack,
|
|
701
|
+
code: data.eventData.errorCode,
|
|
702
|
+
}),
|
|
703
|
+
now,
|
|
704
|
+
now,
|
|
705
|
+
runId,
|
|
706
|
+
);
|
|
707
|
+
run = await requireRun(c, runId);
|
|
708
|
+
await releaseRunHooks(c, runId);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (data.eventType === "run_cancelled") {
|
|
712
|
+
hooksToDelete = (
|
|
713
|
+
await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)
|
|
714
|
+
).map((row) => ({
|
|
715
|
+
hook: rowToHook(row),
|
|
716
|
+
generation:
|
|
717
|
+
row.token_generation == null ? null : Number(row.token_generation),
|
|
718
|
+
}));
|
|
719
|
+
await c.db.execute(
|
|
720
|
+
`
|
|
721
|
+
UPDATE workflow_runs
|
|
722
|
+
SET status = 'cancelled', completed_at = ?, updated_at = ?
|
|
723
|
+
WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
724
|
+
`,
|
|
725
|
+
now,
|
|
726
|
+
now,
|
|
727
|
+
runId,
|
|
728
|
+
);
|
|
729
|
+
run = await requireRun(c, runId);
|
|
730
|
+
await releaseRunHooks(c, runId);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (data.eventType === "step_created") {
|
|
734
|
+
await c.db.execute(
|
|
735
|
+
`
|
|
736
|
+
INSERT OR IGNORE INTO workflow_steps (
|
|
737
|
+
step_id, run_id, step_name, status, input, attempt, created_at, updated_at, spec_version
|
|
738
|
+
)
|
|
739
|
+
VALUES (?, ?, ?, 'pending', ?, 0, ?, ?, ?)
|
|
740
|
+
`,
|
|
741
|
+
data.correlationId,
|
|
742
|
+
runId,
|
|
743
|
+
data.eventData.stepName,
|
|
744
|
+
encodeValue(data.eventData.input),
|
|
745
|
+
now,
|
|
746
|
+
now,
|
|
747
|
+
specVersion,
|
|
748
|
+
);
|
|
749
|
+
const row = await one(
|
|
750
|
+
c,
|
|
751
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
752
|
+
data.correlationId,
|
|
753
|
+
);
|
|
754
|
+
if (row) step = rowToStep(row);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (data.eventType === "step_started") {
|
|
758
|
+
let existing = await one(
|
|
759
|
+
c,
|
|
760
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
761
|
+
data.correlationId,
|
|
762
|
+
);
|
|
763
|
+
const lazyData =
|
|
764
|
+
data.eventData?.stepName != null &&
|
|
765
|
+
data.eventData.input !== undefined
|
|
766
|
+
? {
|
|
767
|
+
stepName: data.eventData.stepName,
|
|
768
|
+
input: data.eventData.input,
|
|
769
|
+
}
|
|
770
|
+
: null;
|
|
771
|
+
if (!existing && lazyData) {
|
|
772
|
+
const inserted = await c.db.execute(
|
|
773
|
+
`
|
|
774
|
+
INSERT OR IGNORE INTO workflow_steps (
|
|
775
|
+
step_id, run_id, step_name, status, input, attempt, created_at, updated_at, spec_version
|
|
776
|
+
)
|
|
777
|
+
VALUES (?, ?, ?, 'pending', ?, 0, ?, ?, ?)
|
|
778
|
+
RETURNING step_id
|
|
779
|
+
`,
|
|
780
|
+
data.correlationId,
|
|
781
|
+
runId,
|
|
782
|
+
lazyData.stepName,
|
|
783
|
+
encodeValue(lazyData.input),
|
|
784
|
+
now,
|
|
785
|
+
now,
|
|
786
|
+
specVersion,
|
|
787
|
+
);
|
|
788
|
+
if (inserted.length === 0) {
|
|
789
|
+
throw new EntityConflictError(
|
|
790
|
+
`Step "${data.correlationId}" already exists`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
stepCreated = true;
|
|
794
|
+
// Lazy step creation must be visible to event-log replay exactly as
|
|
795
|
+
// eager creation is. Both rows live in this actor transaction, so the
|
|
796
|
+
// synthetic creation and the materialized step cannot diverge.
|
|
797
|
+
await insertEvent(
|
|
798
|
+
c,
|
|
799
|
+
runId,
|
|
800
|
+
`wevt_${ulid()}`,
|
|
801
|
+
{
|
|
802
|
+
eventType: "step_created",
|
|
803
|
+
correlationId: data.correlationId,
|
|
804
|
+
eventData: {
|
|
805
|
+
stepName: lazyData.stepName,
|
|
806
|
+
input: lazyData.input,
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
specVersion,
|
|
810
|
+
now,
|
|
811
|
+
);
|
|
812
|
+
existing = await one(
|
|
813
|
+
c,
|
|
814
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
815
|
+
data.correlationId,
|
|
816
|
+
);
|
|
817
|
+
} else if (existing && lazyData) {
|
|
818
|
+
if (
|
|
819
|
+
String(existing.step_name) !== lazyData.stepName ||
|
|
820
|
+
!isDeepStrictEqual(decodeValue(existing.input), lazyData.input)
|
|
821
|
+
) {
|
|
822
|
+
throw new EntityConflictError(
|
|
823
|
+
`Step "${data.correlationId}" already exists with different data`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if (!existing)
|
|
828
|
+
throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
|
|
829
|
+
if (
|
|
830
|
+
String(existing.status) === "running" ||
|
|
831
|
+
terminalSteps.has(String(existing.status))
|
|
832
|
+
) {
|
|
833
|
+
// Vercel may redeliver an accepted step start. Returning the existing
|
|
834
|
+
// step keeps delivery idempotent without incrementing its attempt.
|
|
835
|
+
step = rowToStep(existing);
|
|
836
|
+
} else {
|
|
837
|
+
if (
|
|
838
|
+
existing.retry_after != null &&
|
|
839
|
+
Number(existing.retry_after) > Date.now()
|
|
840
|
+
) {
|
|
841
|
+
throw new TooEarlyError(
|
|
842
|
+
`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`,
|
|
843
|
+
{
|
|
844
|
+
retryAfter: Math.ceil(
|
|
845
|
+
(Number(existing.retry_after) - Date.now()) / 1000,
|
|
846
|
+
),
|
|
847
|
+
},
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
await c.db.execute(
|
|
851
|
+
`
|
|
852
|
+
UPDATE workflow_steps
|
|
853
|
+
SET status = 'running',
|
|
854
|
+
attempt = attempt + 1,
|
|
855
|
+
started_at = COALESCE(started_at, ?),
|
|
856
|
+
retry_after = NULL,
|
|
857
|
+
updated_at = ?
|
|
858
|
+
WHERE step_id = ? AND status NOT IN ('running', 'completed', 'failed', 'cancelled')
|
|
859
|
+
`,
|
|
860
|
+
now,
|
|
861
|
+
now,
|
|
862
|
+
data.correlationId,
|
|
863
|
+
);
|
|
864
|
+
step = rowToStep(
|
|
865
|
+
await one(
|
|
866
|
+
c,
|
|
867
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
868
|
+
data.correlationId,
|
|
869
|
+
),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (data.eventType === "step_completed") {
|
|
875
|
+
const updated = await c.db.execute(
|
|
876
|
+
`
|
|
877
|
+
UPDATE workflow_steps
|
|
878
|
+
SET status = 'completed', output = ?, completed_at = ?, updated_at = ?
|
|
879
|
+
WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
880
|
+
RETURNING *
|
|
881
|
+
`,
|
|
882
|
+
encodeValue(data.eventData.result),
|
|
883
|
+
now,
|
|
884
|
+
now,
|
|
885
|
+
data.correlationId,
|
|
886
|
+
);
|
|
887
|
+
if (updated[0]) {
|
|
888
|
+
step = rowToStep(updated[0]);
|
|
889
|
+
} else {
|
|
890
|
+
const existing = await one(
|
|
891
|
+
c,
|
|
892
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
893
|
+
data.correlationId,
|
|
894
|
+
);
|
|
895
|
+
if (!existing)
|
|
896
|
+
throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
|
|
897
|
+
if (terminalSteps.has(String(existing.status))) {
|
|
898
|
+
throw new EntityConflictError(
|
|
899
|
+
`Cannot modify step in terminal state "${existing.status}"`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
step = rowToStep(existing);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (data.eventType === "step_failed") {
|
|
907
|
+
const error = errorDetails(data.eventData.error);
|
|
908
|
+
const updated = await c.db.execute(
|
|
909
|
+
`
|
|
910
|
+
UPDATE workflow_steps
|
|
911
|
+
SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
|
|
912
|
+
WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
913
|
+
RETURNING *
|
|
914
|
+
`,
|
|
915
|
+
encodeValue({ message: error.message, stack: error.stack }),
|
|
916
|
+
now,
|
|
917
|
+
now,
|
|
918
|
+
data.correlationId,
|
|
919
|
+
);
|
|
920
|
+
if (updated[0]) {
|
|
921
|
+
step = rowToStep(updated[0]);
|
|
922
|
+
} else {
|
|
923
|
+
const existing = await one(
|
|
924
|
+
c,
|
|
925
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
926
|
+
data.correlationId,
|
|
927
|
+
);
|
|
928
|
+
if (!existing)
|
|
929
|
+
throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
|
|
930
|
+
if (terminalSteps.has(String(existing.status))) {
|
|
931
|
+
throw new EntityConflictError(
|
|
932
|
+
`Cannot modify step in terminal state "${existing.status}"`,
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
step = rowToStep(existing);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (data.eventType === "step_retrying") {
|
|
940
|
+
const error = errorDetails(data.eventData.error);
|
|
941
|
+
const updated = await c.db.execute(
|
|
942
|
+
`
|
|
943
|
+
UPDATE workflow_steps
|
|
944
|
+
SET status = 'pending', error = ?, retry_after = ?, updated_at = ?
|
|
945
|
+
WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
|
|
946
|
+
RETURNING *
|
|
947
|
+
`,
|
|
948
|
+
encodeValue({ message: error.message, stack: error.stack }),
|
|
949
|
+
toMs(data.eventData.retryAfter),
|
|
950
|
+
now,
|
|
951
|
+
data.correlationId,
|
|
952
|
+
);
|
|
953
|
+
if (updated[0]) {
|
|
954
|
+
step = rowToStep(updated[0]);
|
|
955
|
+
} else {
|
|
956
|
+
const existing = await one(
|
|
957
|
+
c,
|
|
958
|
+
"SELECT * FROM workflow_steps WHERE step_id = ?",
|
|
959
|
+
data.correlationId,
|
|
960
|
+
);
|
|
961
|
+
if (!existing)
|
|
962
|
+
throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
|
|
963
|
+
if (terminalSteps.has(String(existing.status))) {
|
|
964
|
+
throw new EntityConflictError(
|
|
965
|
+
`Cannot modify step in terminal state "${existing.status}"`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
step = rowToStep(existing);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (data.eventType === "hook_created") {
|
|
973
|
+
const existingCreation = await one(
|
|
974
|
+
c,
|
|
975
|
+
`SELECT 1 FROM workflow_events
|
|
976
|
+
WHERE event_type = 'hook_created' AND correlation_id = ?`,
|
|
977
|
+
data.correlationId,
|
|
978
|
+
);
|
|
979
|
+
if (!existingCreation) await c.db.execute(
|
|
980
|
+
`
|
|
981
|
+
INSERT OR IGNORE INTO workflow_hooks (
|
|
982
|
+
hook_id, run_id, token, token_generation, owner_id, project_id, environment,
|
|
983
|
+
metadata, created_at, spec_version, is_webhook
|
|
984
|
+
)
|
|
985
|
+
VALUES (?, ?, ?, ?, '', '', '', ?, ?, ?, ?)
|
|
986
|
+
`,
|
|
987
|
+
data.correlationId,
|
|
988
|
+
runId,
|
|
989
|
+
data.eventData.token,
|
|
990
|
+
hookSideEffects?.confirm?.generation ?? null,
|
|
991
|
+
encodeValue(data.eventData.metadata),
|
|
992
|
+
now,
|
|
993
|
+
specVersion,
|
|
994
|
+
1,
|
|
995
|
+
);
|
|
996
|
+
const row = await one(
|
|
997
|
+
c,
|
|
998
|
+
"SELECT * FROM workflow_hooks WHERE hook_id = ?",
|
|
999
|
+
data.correlationId,
|
|
1000
|
+
);
|
|
1001
|
+
if (existingCreation && !row) {
|
|
1002
|
+
throw new EntityConflictError(
|
|
1003
|
+
`Hook "${data.correlationId}" was already disposed`,
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
if (row && String(row.token) !== data.eventData.token) {
|
|
1007
|
+
throw new EntityConflictError(
|
|
1008
|
+
`Hook "${data.correlationId}" already uses a different token`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
if (row) hook = rowToHook(row);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (data.eventType === "hook_disposed") {
|
|
1015
|
+
const existingHook = await one(
|
|
1016
|
+
c,
|
|
1017
|
+
"SELECT * FROM workflow_hooks WHERE hook_id = ?",
|
|
1018
|
+
data.correlationId,
|
|
1019
|
+
);
|
|
1020
|
+
if (existingHook) {
|
|
1021
|
+
hooksToDelete.push({
|
|
1022
|
+
hook: rowToHook(existingHook),
|
|
1023
|
+
generation:
|
|
1024
|
+
existingHook.token_generation == null
|
|
1025
|
+
? null
|
|
1026
|
+
: Number(existingHook.token_generation),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
await c.db.execute(
|
|
1030
|
+
"DELETE FROM workflow_hooks WHERE hook_id = ?",
|
|
1031
|
+
data.correlationId,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (data.eventType === "wait_created") {
|
|
1036
|
+
const waitId = `${runId}-${data.correlationId}`;
|
|
1037
|
+
const resumeAt = toMs(data.eventData.resumeAt);
|
|
1038
|
+
await c.db.execute(
|
|
1039
|
+
`
|
|
1040
|
+
INSERT OR IGNORE INTO workflow_waits (
|
|
1041
|
+
wait_id, run_id, status, resume_at, created_at, updated_at, spec_version
|
|
1042
|
+
)
|
|
1043
|
+
VALUES (?, ?, 'waiting', ?, ?, ?, ?)
|
|
1044
|
+
RETURNING wait_id
|
|
1045
|
+
`,
|
|
1046
|
+
waitId,
|
|
1047
|
+
runId,
|
|
1048
|
+
resumeAt,
|
|
1049
|
+
now,
|
|
1050
|
+
now,
|
|
1051
|
+
specVersion,
|
|
1052
|
+
);
|
|
1053
|
+
wait = rowToWait(
|
|
1054
|
+
await one(c, "SELECT * FROM workflow_waits WHERE wait_id = ?", waitId),
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (data.eventType === "wait_completed") {
|
|
1059
|
+
const waitId = `${runId}-${data.correlationId}`;
|
|
1060
|
+
await c.db.execute(
|
|
1061
|
+
`
|
|
1062
|
+
UPDATE workflow_waits
|
|
1063
|
+
SET status = 'completed', completed_at = ?, updated_at = ?
|
|
1064
|
+
WHERE wait_id = ? AND status = 'waiting'
|
|
1065
|
+
`,
|
|
1066
|
+
now,
|
|
1067
|
+
now,
|
|
1068
|
+
waitId,
|
|
1069
|
+
);
|
|
1070
|
+
const row = await one(
|
|
1071
|
+
c,
|
|
1072
|
+
"SELECT * FROM workflow_waits WHERE wait_id = ?",
|
|
1073
|
+
waitId,
|
|
1074
|
+
);
|
|
1075
|
+
if (row) wait = rowToWait(row);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const event = await insertEvent(
|
|
1079
|
+
c,
|
|
1080
|
+
runId,
|
|
1081
|
+
`wevt_${ulid()}`,
|
|
1082
|
+
data,
|
|
1083
|
+
specVersion,
|
|
1084
|
+
now,
|
|
1085
|
+
);
|
|
1086
|
+
await c.db.execute(
|
|
1087
|
+
"UPDATE workflow_runs SET run_revision = run_revision + 1 WHERE run_id = ?",
|
|
1088
|
+
runId,
|
|
1089
|
+
);
|
|
1090
|
+
const revisionRow = await one(
|
|
1091
|
+
c,
|
|
1092
|
+
"SELECT run_revision FROM workflow_runs WHERE run_id = ?",
|
|
1093
|
+
runId,
|
|
1094
|
+
);
|
|
1095
|
+
const runRevision = Number(revisionRow?.run_revision ?? 0);
|
|
1096
|
+
if (run) {
|
|
1097
|
+
run = await requireRun(c, runId);
|
|
1098
|
+
await addPendingOp(c, "coordinator:run", runRevision, {
|
|
1099
|
+
type: "run.upsert",
|
|
1100
|
+
run,
|
|
1101
|
+
runRevision,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
const indexCorrelationId =
|
|
1105
|
+
"correlationId" in data ? data.correlationId : undefined;
|
|
1106
|
+
if (
|
|
1107
|
+
indexCorrelationId != null &&
|
|
1108
|
+
(data.eventType === "step_created" ||
|
|
1109
|
+
stepCreated === true ||
|
|
1110
|
+
data.eventType === "hook_created" ||
|
|
1111
|
+
data.eventType === "wait_created")
|
|
1112
|
+
) {
|
|
1113
|
+
await addPendingOp(c, `coordinator:correlation:${indexCorrelationId}`, runRevision, {
|
|
1114
|
+
type: "correlation.put",
|
|
1115
|
+
correlationId: indexCorrelationId,
|
|
1116
|
+
runId,
|
|
1117
|
+
kind:
|
|
1118
|
+
data.eventType === "step_created" || stepCreated === true
|
|
1119
|
+
? "step"
|
|
1120
|
+
: data.eventType === "hook_created"
|
|
1121
|
+
? "hook"
|
|
1122
|
+
: "wait",
|
|
1123
|
+
runRevision,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
if (data.eventType === "hook_created" && hook) {
|
|
1127
|
+
await addPendingOp(c, `coordinator:hook:${hook.hookId}`, runRevision, {
|
|
1128
|
+
type: "hook.upsert",
|
|
1129
|
+
hookId: hook.hookId,
|
|
1130
|
+
runId,
|
|
1131
|
+
token: hook.token,
|
|
1132
|
+
runRevision,
|
|
1133
|
+
createdAt: hook.createdAt,
|
|
1134
|
+
updatedAt: hook.createdAt,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
for (const { hook: deletedHook, generation } of hooksToDelete) {
|
|
1138
|
+
await addPendingOp(c, `coordinator:hook:${deletedHook.hookId}`, runRevision, {
|
|
1139
|
+
type: "hook.delete",
|
|
1140
|
+
hookId: deletedHook.hookId,
|
|
1141
|
+
runId,
|
|
1142
|
+
token: deletedHook.token,
|
|
1143
|
+
runRevision,
|
|
1144
|
+
createdAt: deletedHook.createdAt,
|
|
1145
|
+
updatedAt: Date.now(),
|
|
1146
|
+
});
|
|
1147
|
+
await addPendingOp(
|
|
1148
|
+
c,
|
|
1149
|
+
`coordinator:correlation:${deletedHook.hookId}`,
|
|
1150
|
+
runRevision,
|
|
1151
|
+
{
|
|
1152
|
+
type: "correlation.delete",
|
|
1153
|
+
correlationId: deletedHook.hookId,
|
|
1154
|
+
runId,
|
|
1155
|
+
kind: "hook",
|
|
1156
|
+
runRevision,
|
|
1157
|
+
},
|
|
1158
|
+
);
|
|
1159
|
+
if (generation != null) {
|
|
1160
|
+
await addPendingOp(c, `hook-token:${deletedHook.token}`, runRevision, {
|
|
1161
|
+
type: "hook.release",
|
|
1162
|
+
token: deletedHook.token,
|
|
1163
|
+
hookId: deletedHook.hookId,
|
|
1164
|
+
generation,
|
|
1165
|
+
runId,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (hookSideEffects?.confirm) {
|
|
1170
|
+
await addPendingOp(c, `hook-token:${hookSideEffects.confirm.token}`, runRevision, {
|
|
1171
|
+
type: "hook.confirm",
|
|
1172
|
+
runId,
|
|
1173
|
+
...hookSideEffects.confirm,
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
let events: Event[] | undefined;
|
|
1178
|
+
let cursor: string | null | undefined;
|
|
1179
|
+
let hasMore: boolean | undefined;
|
|
1180
|
+
if (data.eventType === "run_started" && run) {
|
|
1181
|
+
events = (await listEventsForRun(c, runId)).map((item) =>
|
|
1182
|
+
stripEventDataRefs(item, resolveData),
|
|
1183
|
+
);
|
|
1184
|
+
cursor = events.at(-1)?.eventId ?? null;
|
|
1185
|
+
hasMore = false;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return {
|
|
1189
|
+
event: stripEventDataRefs(event, resolveData),
|
|
1190
|
+
run: run && (filterData(run, resolveData) as WorkflowRun),
|
|
1191
|
+
step: step && (filterData(step, resolveData) as Step),
|
|
1192
|
+
hook: hook && (filterHookData(hook, resolveData) as Hook),
|
|
1193
|
+
wait,
|
|
1194
|
+
stepCreated,
|
|
1195
|
+
events,
|
|
1196
|
+
cursor,
|
|
1197
|
+
hasMore,
|
|
1198
|
+
} as EventResult;
|
|
1199
|
+
});
|
|
1200
|
+
if (data.eventType === "run_created" && initialDispatch) {
|
|
1201
|
+
startDispatchLoop(wfc, actorRunId);
|
|
1202
|
+
}
|
|
1203
|
+
await wfc.schedule.after(0, "wake");
|
|
1204
|
+
startPendingDrain(wfc);
|
|
1205
|
+
return result;
|
|
1206
|
+
});
|
|
1207
|
+
},
|
|
1208
|
+
getRun: async (c, runId: string, params?: GetWorkflowRunParams) => {
|
|
1209
|
+
assertRunKey(c, runId);
|
|
1210
|
+
return filterData(
|
|
1211
|
+
await requireRun(c, runId),
|
|
1212
|
+
params?.resolveData,
|
|
1213
|
+
) as WorkflowRun;
|
|
1214
|
+
},
|
|
1215
|
+
getEvent: async (
|
|
1216
|
+
c,
|
|
1217
|
+
runId: string,
|
|
1218
|
+
eventId: string,
|
|
1219
|
+
params?: GetEventParams,
|
|
1220
|
+
) => {
|
|
1221
|
+
assertRunKey(c, runId);
|
|
1222
|
+
const row = await one(
|
|
1223
|
+
c,
|
|
1224
|
+
`
|
|
1225
|
+
SELECT * FROM workflow_events
|
|
1226
|
+
WHERE run_id = ? AND event_id = ?
|
|
1227
|
+
`,
|
|
1228
|
+
runId,
|
|
1229
|
+
eventId,
|
|
1230
|
+
);
|
|
1231
|
+
if (!row) throw new WorkflowWorldError(`Event not found: ${eventId}`);
|
|
1232
|
+
return stripEventDataRefs(rowToEvent(row), params?.resolveData ?? "all");
|
|
1233
|
+
},
|
|
1234
|
+
listEvents: async (c, params: ListEventsParams) => {
|
|
1235
|
+
assertRunKey(c, params.runId);
|
|
1236
|
+
const limit = params.pagination?.limit ?? 100;
|
|
1237
|
+
const cursor = params.pagination?.cursor;
|
|
1238
|
+
const desc = params.pagination?.sortOrder === "desc";
|
|
1239
|
+
const rows = await c.db.execute(
|
|
1240
|
+
`
|
|
1241
|
+
SELECT * FROM workflow_events
|
|
1242
|
+
WHERE run_id = ? AND (? IS NULL OR event_id ${desc ? "<" : ">"} ?)
|
|
1243
|
+
ORDER BY event_id ${desc ? "DESC" : "ASC"}
|
|
1244
|
+
LIMIT ?
|
|
1245
|
+
`,
|
|
1246
|
+
params.runId,
|
|
1247
|
+
cursor ?? null,
|
|
1248
|
+
cursor ?? null,
|
|
1249
|
+
limit + 1,
|
|
1250
|
+
);
|
|
1251
|
+
const values = rows.slice(0, limit);
|
|
1252
|
+
return {
|
|
1253
|
+
data: values.map((row) =>
|
|
1254
|
+
stripEventDataRefs(rowToEvent(row), params.resolveData ?? "all"),
|
|
1255
|
+
),
|
|
1256
|
+
cursor: values.at(-1)?.event_id?.toString() ?? null,
|
|
1257
|
+
hasMore: rows.length > limit,
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
listEventsByCorrelationId: async (
|
|
1261
|
+
c,
|
|
1262
|
+
params: ListEventsByCorrelationIdParams,
|
|
1263
|
+
) => {
|
|
1264
|
+
const limit = params.pagination?.limit ?? 100;
|
|
1265
|
+
const cursor = params.pagination?.cursor;
|
|
1266
|
+
const rows = await c.db.execute(
|
|
1267
|
+
`
|
|
1268
|
+
SELECT * FROM workflow_events
|
|
1269
|
+
WHERE correlation_id = ? AND (? IS NULL OR event_id > ?)
|
|
1270
|
+
ORDER BY event_id ASC
|
|
1271
|
+
LIMIT ?
|
|
1272
|
+
`,
|
|
1273
|
+
params.correlationId,
|
|
1274
|
+
cursor ?? null,
|
|
1275
|
+
cursor ?? null,
|
|
1276
|
+
limit + 1,
|
|
1277
|
+
);
|
|
1278
|
+
const values = rows.slice(0, limit);
|
|
1279
|
+
return {
|
|
1280
|
+
data: values.map((row) =>
|
|
1281
|
+
stripEventDataRefs(rowToEvent(row), params.resolveData ?? "all"),
|
|
1282
|
+
),
|
|
1283
|
+
cursor: values.at(-1)?.event_id?.toString() ?? null,
|
|
1284
|
+
hasMore: rows.length > limit,
|
|
1285
|
+
};
|
|
1286
|
+
},
|
|
1287
|
+
getStep: async (
|
|
1288
|
+
c,
|
|
1289
|
+
runId: string | undefined,
|
|
1290
|
+
stepId: string,
|
|
1291
|
+
params?: GetStepParams,
|
|
1292
|
+
) => {
|
|
1293
|
+
if (runId) assertRunKey(c, runId);
|
|
1294
|
+
const row = runId
|
|
1295
|
+
? await one(
|
|
1296
|
+
c,
|
|
1297
|
+
"SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?",
|
|
1298
|
+
runId,
|
|
1299
|
+
stepId,
|
|
1300
|
+
)
|
|
1301
|
+
: await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", stepId);
|
|
1302
|
+
if (!row) throw new WorkflowWorldError(`Step not found: ${stepId}`);
|
|
1303
|
+
return filterData(rowToStep(row), params?.resolveData) as Step;
|
|
1304
|
+
},
|
|
1305
|
+
listSteps: async (c, params: ListWorkflowRunStepsParams) => {
|
|
1306
|
+
assertRunKey(c, params.runId);
|
|
1307
|
+
const limit = params.pagination?.limit ?? 20;
|
|
1308
|
+
const cursor = params.pagination?.cursor;
|
|
1309
|
+
const rows = await c.db.execute(
|
|
1310
|
+
`
|
|
1311
|
+
SELECT * FROM workflow_steps
|
|
1312
|
+
WHERE run_id = ? AND (? IS NULL OR step_id < ?)
|
|
1313
|
+
ORDER BY step_id DESC
|
|
1314
|
+
LIMIT ?
|
|
1315
|
+
`,
|
|
1316
|
+
params.runId,
|
|
1317
|
+
cursor ?? null,
|
|
1318
|
+
cursor ?? null,
|
|
1319
|
+
limit + 1,
|
|
1320
|
+
);
|
|
1321
|
+
const values = rows.slice(0, limit);
|
|
1322
|
+
return {
|
|
1323
|
+
data: values.map((row) =>
|
|
1324
|
+
filterData(rowToStep(row), params.resolveData),
|
|
1325
|
+
),
|
|
1326
|
+
cursor: values.at(-1)?.step_id?.toString() ?? null,
|
|
1327
|
+
hasMore: rows.length > limit,
|
|
1328
|
+
};
|
|
1329
|
+
},
|
|
1330
|
+
getHook: async (c, hookId: string, params?: GetHookParams) => {
|
|
1331
|
+
const row = await one(
|
|
1332
|
+
c,
|
|
1333
|
+
"SELECT * FROM workflow_hooks WHERE hook_id = ?",
|
|
1334
|
+
hookId,
|
|
1335
|
+
);
|
|
1336
|
+
if (!row) throw new HookNotFoundError(hookId);
|
|
1337
|
+
return filterHookData(rowToHook(row), params?.resolveData) as Hook;
|
|
1338
|
+
},
|
|
1339
|
+
ownsHook: async (c, hookId: string, token: string) => {
|
|
1340
|
+
const runId = c.key[0];
|
|
1341
|
+
if (!runId) throw new Error("workflowRun actor key is missing");
|
|
1342
|
+
const row = await one(
|
|
1343
|
+
c,
|
|
1344
|
+
`
|
|
1345
|
+
SELECT 1 AS owns_hook
|
|
1346
|
+
FROM workflow_hooks
|
|
1347
|
+
WHERE run_id = ? AND hook_id = ? AND token = ?
|
|
1348
|
+
`,
|
|
1349
|
+
runId,
|
|
1350
|
+
hookId,
|
|
1351
|
+
token,
|
|
1352
|
+
);
|
|
1353
|
+
return row != null;
|
|
1354
|
+
},
|
|
1355
|
+
listHooks: async (c, params: ListHooksParams) => {
|
|
1356
|
+
if (params.runId) assertRunKey(c, params.runId);
|
|
1357
|
+
const limit = params.pagination?.limit ?? 100;
|
|
1358
|
+
const cursor = params.pagination?.cursor;
|
|
1359
|
+
const rows = await c.db.execute(
|
|
1360
|
+
`
|
|
1361
|
+
SELECT * FROM workflow_hooks
|
|
1362
|
+
WHERE (? IS NULL OR run_id = ?) AND (? IS NULL OR hook_id > ?)
|
|
1363
|
+
ORDER BY hook_id ASC
|
|
1364
|
+
LIMIT ?
|
|
1365
|
+
`,
|
|
1366
|
+
params.runId ?? null,
|
|
1367
|
+
params.runId ?? null,
|
|
1368
|
+
cursor ?? null,
|
|
1369
|
+
cursor ?? null,
|
|
1370
|
+
limit + 1,
|
|
1371
|
+
);
|
|
1372
|
+
const values = rows.slice(0, limit);
|
|
1373
|
+
return {
|
|
1374
|
+
data: values.map((row) =>
|
|
1375
|
+
filterHookData(rowToHook(row), params.resolveData),
|
|
1376
|
+
),
|
|
1377
|
+
cursor: values.at(-1)?.hook_id?.toString() ?? null,
|
|
1378
|
+
hasMore: rows.length > limit,
|
|
1379
|
+
};
|
|
1380
|
+
},
|
|
1381
|
+
wake: async (c) => {
|
|
1382
|
+
const ctx = asWorkflowContext(c);
|
|
1383
|
+
await withSerializedAction(ctx, async () => {
|
|
1384
|
+
await prearmRecovery(ctx);
|
|
1385
|
+
ctx.vars.wakeArmedAt = null;
|
|
1386
|
+
await cleanupExpiredStreams(ctx);
|
|
1387
|
+
await drainPendingOps(ctx);
|
|
1388
|
+
startDispatchLoop(ctx, ctx.key[0] ?? "__health");
|
|
1389
|
+
await finishRecoveryArm(ctx);
|
|
1390
|
+
});
|
|
1391
|
+
},
|
|
1392
|
+
enqueue: (
|
|
1393
|
+
c,
|
|
1394
|
+
partition: string,
|
|
1395
|
+
messageId: string,
|
|
1396
|
+
queueName: string,
|
|
1397
|
+
runtimeUrl: string,
|
|
1398
|
+
body: string,
|
|
1399
|
+
headers?: Record<string, string>,
|
|
1400
|
+
delaySeconds?: number,
|
|
1401
|
+
idempotencyKey?: string,
|
|
1402
|
+
) => {
|
|
1403
|
+
const ctx = asWorkflowContext(c);
|
|
1404
|
+
return withRecoveryWriter(ctx, () =>
|
|
1405
|
+
enqueueDispatch(
|
|
1406
|
+
ctx,
|
|
1407
|
+
partition,
|
|
1408
|
+
messageId,
|
|
1409
|
+
queueName,
|
|
1410
|
+
runtimeUrl,
|
|
1411
|
+
body,
|
|
1412
|
+
headers,
|
|
1413
|
+
delaySeconds,
|
|
1414
|
+
idempotencyKey,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
},
|
|
1418
|
+
inspectQueue: (c) => inspectQueue(c),
|
|
1419
|
+
forceStaleInflightForTesting: (
|
|
1420
|
+
c,
|
|
1421
|
+
partition: string,
|
|
1422
|
+
messageId: string,
|
|
1423
|
+
ageMs?: number,
|
|
1424
|
+
) =>
|
|
1425
|
+
forceStaleInflightForTesting(
|
|
1426
|
+
c as unknown as WorkflowContext,
|
|
1427
|
+
partition,
|
|
1428
|
+
messageId,
|
|
1429
|
+
ageMs,
|
|
1430
|
+
),
|
|
1431
|
+
writeStream: async (
|
|
1432
|
+
c,
|
|
1433
|
+
streamId: string,
|
|
1434
|
+
runId: string,
|
|
1435
|
+
chunk: string,
|
|
1436
|
+
eof: boolean,
|
|
1437
|
+
) => {
|
|
1438
|
+
const ctx = asWorkflowContext(c);
|
|
1439
|
+
return withRecoveryWriter(ctx, async () => {
|
|
1440
|
+
const result = await writeStream(ctx, streamId, runId, chunk, eof);
|
|
1441
|
+
if (eof && STREAM_TTL_MS > 0) {
|
|
1442
|
+
await ctx.schedule.at(Date.now() + STREAM_TTL_MS, "wake");
|
|
1443
|
+
}
|
|
1444
|
+
return result;
|
|
1445
|
+
});
|
|
1446
|
+
},
|
|
1447
|
+
getStreamChunks: (
|
|
1448
|
+
c,
|
|
1449
|
+
streamId: string,
|
|
1450
|
+
runId: string,
|
|
1451
|
+
startIndex?: number,
|
|
1452
|
+
limit?: number,
|
|
1453
|
+
) => getStreamChunks(c as unknown as WorkflowContext, streamId, runId, startIndex, limit),
|
|
1454
|
+
getStreamInfo: (c, streamId: string, runId: string) =>
|
|
1455
|
+
getStreamInfo(c as unknown as WorkflowContext, streamId, runId),
|
|
1456
|
+
listStreams: (c, runId: string) =>
|
|
1457
|
+
listStreams(c as unknown as WorkflowContext, runId),
|
|
1458
|
+
expireClosedStreamForTesting: (c, streamId: string, runId: string) =>
|
|
1459
|
+
expireClosedStreamForTesting(
|
|
1460
|
+
c as unknown as WorkflowContext,
|
|
1461
|
+
streamId,
|
|
1462
|
+
runId,
|
|
1463
|
+
),
|
|
1464
|
+
},
|
|
1465
|
+
options: { sleepTimeout: 1000 },
|
|
1466
|
+
});
|