@rivet-dev/vercel-world 0.0.0-http-body-streaming.064e07d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +31 -0
  3. package/dist/actors/coordinator.d.ts +114 -0
  4. package/dist/actors/coordinator.d.ts.map +1 -0
  5. package/dist/actors/coordinator.js +232 -0
  6. package/dist/actors/coordinator.js.map +1 -0
  7. package/dist/actors/db.d.ts +8 -0
  8. package/dist/actors/db.d.ts.map +1 -0
  9. package/dist/actors/db.js +282 -0
  10. package/dist/actors/db.js.map +1 -0
  11. package/dist/actors/dispatcher.d.ts +84 -0
  12. package/dist/actors/dispatcher.d.ts.map +1 -0
  13. package/dist/actors/dispatcher.js +498 -0
  14. package/dist/actors/dispatcher.js.map +1 -0
  15. package/dist/actors/hook-token.d.ts +26 -0
  16. package/dist/actors/hook-token.d.ts.map +1 -0
  17. package/dist/actors/hook-token.js +151 -0
  18. package/dist/actors/hook-token.js.map +1 -0
  19. package/dist/actors/shared.d.ts +366 -0
  20. package/dist/actors/shared.d.ts.map +1 -0
  21. package/dist/actors/shared.js +112 -0
  22. package/dist/actors/shared.js.map +1 -0
  23. package/dist/actors/streams.d.ts +26 -0
  24. package/dist/actors/streams.d.ts.map +1 -0
  25. package/dist/actors/streams.js +127 -0
  26. package/dist/actors/streams.js.map +1 -0
  27. package/dist/actors/workflow-run.d.ts +891 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +872 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2244 -0
  32. package/dist/actors.d.ts.map +1 -0
  33. package/dist/actors.js +16 -0
  34. package/dist/actors.js.map +1 -0
  35. package/dist/codec.d.ts +4 -0
  36. package/dist/codec.d.ts.map +1 -0
  37. package/dist/codec.js +27 -0
  38. package/dist/codec.js.map +1 -0
  39. package/dist/index.d.ts +843 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +567 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/runtime.d.ts +2 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +24 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/package.json +51 -0
  48. package/scripts/conformance/run.ts +278 -0
  49. package/scripts/integration/run.ts +935 -0
  50. package/src/actors/coordinator.ts +360 -0
  51. package/src/actors/db.ts +291 -0
  52. package/src/actors/dispatcher.ts +787 -0
  53. package/src/actors/hook-token.ts +239 -0
  54. package/src/actors/shared.ts +153 -0
  55. package/src/actors/streams.ts +215 -0
  56. package/src/actors/workflow-run.ts +1477 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +788 -0
  60. package/src/runtime.ts +29 -0
  61. package/tests/conformance.test.ts +8 -0
  62. package/tests/helpers/db.ts +62 -0
  63. package/tests/helpers/dispatcher-driver.ts +71 -0
  64. package/tests/helpers/harness.ts +161 -0
  65. package/tests/integration/crash-restart.test.ts +145 -0
  66. package/tests/integration/dispatcher-loop.test.ts +144 -0
  67. package/tests/integration/hook-token.test.ts +160 -0
  68. package/tests/integration/hooks.test.ts +123 -0
  69. package/tests/integration/streams.test.ts +178 -0
  70. package/tests/integration/workflow-events.test.ts +326 -0
  71. package/tests/setup.ts +10 -0
  72. package/tests/unit/codec.test.ts +73 -0
  73. package/tests/unit/coordinator-record.test.ts +177 -0
  74. package/tests/unit/db-migrations.test.ts +65 -0
  75. package/tests/unit/dispatcher-queue.test.ts +274 -0
  76. package/tests/unit/logging.test.ts +49 -0
  77. package/tests/unit/readiness.test.ts +102 -0
  78. package/tests/unit/transaction.test.ts +76 -0
  79. package/tsconfig.build.json +13 -0
  80. package/tsconfig.json +12 -0
  81. package/vitest.config.ts +32 -0
@@ -0,0 +1,872 @@
1
+ import { EntityConflictError, HookNotFoundError, TooEarlyError, WorkflowRunNotFoundError, WorkflowWorldError, } from "@workflow/errors";
2
+ import { SPEC_VERSION_CURRENT, stripEventDataRefs, } from "@workflow/world";
3
+ import { actor } from "rivetkit";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { monotonicFactory } from "ulid";
6
+ import { decodeValue, encodeValue } from "../codec.js";
7
+ import { workflowDb } from "./db.js";
8
+ import { createDispatcherVars, enqueueDispatch, forceStaleInflightForTesting, insertDispatch, inspectQueue, nextInflightRecoveryAt, startDispatchLoop, } from "./dispatcher.js";
9
+ import { expireClosedStreamForTesting, cleanupExpiredStreams, getStreamChunks, getStreamInfo, listStreams, STREAM_TTL_MS, streamAppended, writeStream, } from "./streams.js";
10
+ import { filterData, filterHookData, one, requireRun, rowToEvent, rowToHook, rowToStep, rowToWait, toMs, withActorTransaction, } from "./shared.js";
11
+ const ulid = monotonicFactory();
12
+ const terminalRuns = new Set(["completed", "failed", "cancelled"]);
13
+ const terminalSteps = new Set(["completed", "failed", "cancelled"]);
14
+ const creationEvents = new Set([
15
+ "run_created",
16
+ "step_created",
17
+ "hook_created",
18
+ "wait_created",
19
+ ]);
20
+ const RECOVERY_CRON = "vercel-world-recovery";
21
+ const RECOVERY_INTERVAL_MS = 5_000;
22
+ async function prearmRecovery(c) {
23
+ // Recurring schedules advance before dispatch. If a fire is consumed while
24
+ // this action is committing, the next recovery fire remains durable.
25
+ if (c.vars.recoveryDeletePromise) {
26
+ await c.vars.recoveryDeletePromise;
27
+ }
28
+ if (!c.vars.recoveryPrearmPromise) {
29
+ const arm = c.cron.every({
30
+ name: RECOVERY_CRON,
31
+ interval: RECOVERY_INTERVAL_MS,
32
+ action: "wake",
33
+ args: [],
34
+ maxHistory: 0,
35
+ });
36
+ c.vars.recoveryPrearmPromise = arm;
37
+ void arm.catch(() => {
38
+ if (c.vars.recoveryPrearmPromise === arm) {
39
+ c.vars.recoveryPrearmPromise = null;
40
+ }
41
+ });
42
+ }
43
+ await c.vars.recoveryPrearmPromise;
44
+ }
45
+ async function finishRecoveryArm(c) {
46
+ const nextWakeAt = await armNextWake(c);
47
+ // An immediately-due one-shot may already be claimed and waiting behind this
48
+ // action, so keep the recurring backstop until a later wake drains that work.
49
+ // A wake safely beyond the next backstop interval cannot be claimed during
50
+ // this action; the exact durable alarm is sufficient until then.
51
+ if (c.vars.recoveryWriters === 0 &&
52
+ !c.vars.recoveryDeletePromise &&
53
+ (nextWakeAt == null || nextWakeAt > Date.now() + RECOVERY_INTERVAL_MS)) {
54
+ const deletion = c.cron.delete(RECOVERY_CRON).then(() => undefined);
55
+ const tracked = deletion.finally(() => {
56
+ // A failed delete is ambiguous: force the next writer to re-arm after the
57
+ // delete settles instead of assuming the named cron still exists.
58
+ c.vars.recoveryPrearmPromise = null;
59
+ if (c.vars.recoveryDeletePromise === tracked) {
60
+ c.vars.recoveryDeletePromise = null;
61
+ }
62
+ });
63
+ c.vars.recoveryDeletePromise = tracked;
64
+ await tracked;
65
+ }
66
+ }
67
+ const createWorkflowVars = () => ({
68
+ ...createDispatcherVars(),
69
+ recoveryPrearmPromise: null,
70
+ recoveryDeletePromise: null,
71
+ recoveryWriters: 0,
72
+ pendingDraining: false,
73
+ pendingGeneration: 0,
74
+ });
75
+ function asWorkflowContext(c) {
76
+ const ctx = c;
77
+ ctx.armNextWake = async () => {
78
+ await armNextWake(ctx);
79
+ };
80
+ return ctx;
81
+ }
82
+ async function withSerializedAction(c, fn) {
83
+ const previous = c.vars.serialTail ?? Promise.resolve();
84
+ let release;
85
+ const current = new Promise((resolve) => {
86
+ release = resolve;
87
+ });
88
+ c.vars.serialTail = previous.then(() => current);
89
+ await previous;
90
+ try {
91
+ return await fn();
92
+ }
93
+ finally {
94
+ release();
95
+ }
96
+ }
97
+ async function withRecoveryWriter(c, fn) {
98
+ c.vars.recoveryWriters++;
99
+ try {
100
+ await prearmRecovery(c);
101
+ return await fn();
102
+ }
103
+ finally {
104
+ try {
105
+ await armNextWake(c);
106
+ }
107
+ catch {
108
+ // The recurring recovery cron remains the durable fallback.
109
+ }
110
+ finally {
111
+ c.vars.recoveryWriters--;
112
+ }
113
+ }
114
+ }
115
+ function assertRunKey(c, runId) {
116
+ const actorRunId = c.key[0];
117
+ if (!actorRunId || actorRunId !== runId) {
118
+ throw new Error(`workflowRun actor key mismatch: expected ${actorRunId ?? "<missing>"}, received ${runId}`);
119
+ }
120
+ }
121
+ function errorDetails(error) {
122
+ if (typeof error === "string")
123
+ return { message: error };
124
+ if (error instanceof Error) {
125
+ return { message: error.message, stack: error.stack };
126
+ }
127
+ return { message: "Unknown error" };
128
+ }
129
+ async function currentRunStatus(c, runId) {
130
+ const row = await one(c, "SELECT status FROM workflow_runs WHERE run_id = ?", runId);
131
+ return row?.status == null ? null : String(row.status);
132
+ }
133
+ async function insertEvent(c, runId, eventId, data, specVersion, now) {
134
+ // Preserve eventData for every event that carries it — including run_started,
135
+ // whose optional creation fields back the resilient-start recovery path
136
+ // (recreate the run from run_started when run_created was lost; SPEC §3.2,
137
+ // `@workflow/world` queue.d.ts).
138
+ const storedEventData = "eventData" in data ? data.eventData : undefined;
139
+ const dedupeCreation = creationEvents.has(data.eventType);
140
+ await c.db.execute(`
141
+ INSERT ${dedupeCreation ? "OR IGNORE" : ""} INTO workflow_events (
142
+ event_id, run_id, event_type, correlation_id, event_data, spec_version, created_at
143
+ )
144
+ VALUES (?, ?, ?, ?, ?, ?, ?)
145
+ `, eventId, runId, data.eventType, "correlationId" in data ? data.correlationId : null, encodeValue(storedEventData), specVersion, now);
146
+ const inserted = await one(c, "SELECT * FROM workflow_events WHERE event_id = ?", eventId);
147
+ if (inserted)
148
+ return rowToEvent(inserted);
149
+ if (dedupeCreation) {
150
+ const existing = data.eventType === "run_created"
151
+ ? await one(c, "SELECT * FROM workflow_events WHERE run_id = ? AND event_type = 'run_created'", runId)
152
+ : await one(c, "SELECT * FROM workflow_events WHERE run_id = ? AND event_type = ? AND correlation_id = ?", runId, data.eventType, "correlationId" in data ? data.correlationId : null);
153
+ if (existing)
154
+ return rowToEvent(existing);
155
+ }
156
+ return rowToEvent({
157
+ ...data,
158
+ run_id: runId,
159
+ event_id: eventId,
160
+ event_type: data.eventType,
161
+ correlation_id: "correlationId" in data ? data.correlationId : null,
162
+ event_data: encodeValue(storedEventData),
163
+ spec_version: specVersion,
164
+ created_at: now,
165
+ });
166
+ }
167
+ async function listEventsForRun(c, runId) {
168
+ return (await c.db.execute("SELECT * FROM workflow_events WHERE run_id = ? ORDER BY event_id", runId)).map(rowToEvent);
169
+ }
170
+ async function releaseRunHooks(c, runId) {
171
+ await c.db.execute("DELETE FROM workflow_hooks WHERE run_id = ?", runId);
172
+ await c.db.execute("DELETE FROM workflow_waits WHERE run_id = ?", runId);
173
+ }
174
+ const OUTBOX_CLAIM_MS = 60_000;
175
+ const OUTBOX_BATCH = 32;
176
+ const OUTBOX_DRAIN_BUDGET_MS = 5_000;
177
+ async function withinDeadline(promise, deadline) {
178
+ const remaining = deadline - Date.now();
179
+ if (remaining <= 0)
180
+ throw new Error("pending operation drain exceeded its time budget");
181
+ let timeout;
182
+ try {
183
+ return await Promise.race([
184
+ promise,
185
+ new Promise((_, reject) => {
186
+ timeout = setTimeout(() => reject(new Error("pending operation drain exceeded its time budget")), remaining);
187
+ }),
188
+ ]);
189
+ }
190
+ finally {
191
+ if (timeout)
192
+ clearTimeout(timeout);
193
+ }
194
+ }
195
+ async function armNextWake(c) {
196
+ const [dispatchReady, dispatchInflight, pending, streamExpiry] = await Promise.all([
197
+ one(c, "SELECT MIN(next_at) AS due FROM dispatcher_queue WHERE status = 'ready'"),
198
+ nextInflightRecoveryAt(c, c.vars.activeMessageIds).then((due) => due == null ? undefined : { due }),
199
+ one(c, "SELECT MIN(next_at) AS due FROM pending_ops"),
200
+ STREAM_TTL_MS === 0
201
+ ? Promise.resolve(undefined)
202
+ : one(c, `SELECT MIN(created_at + ?) AS due FROM stream_chunks
203
+ WHERE eof = 1`, STREAM_TTL_MS),
204
+ ]);
205
+ const due = [
206
+ dispatchReady?.due,
207
+ dispatchInflight?.due,
208
+ pending?.due,
209
+ streamExpiry?.due,
210
+ ]
211
+ .filter((value) => value != null)
212
+ .map(Number)
213
+ .filter(Number.isFinite);
214
+ if (due.length === 0)
215
+ return null;
216
+ const target = Math.min(...due);
217
+ if (c.vars.wakeArmedAt != null && c.vars.wakeArmedAt <= target) {
218
+ return c.vars.wakeArmedAt;
219
+ }
220
+ await c.schedule.at(Math.max(Date.now(), target), "wake");
221
+ c.vars.wakeArmedAt = target;
222
+ return target;
223
+ }
224
+ async function addPendingOp(c, opId, runRevision, payload) {
225
+ const now = Date.now();
226
+ await c.db.execute(`INSERT INTO pending_ops (
227
+ op_id, op_type, payload, operation_revision, status, next_at,
228
+ attempt, created_at, updated_at
229
+ ) VALUES (?, ?, ?, ?, 'ready', ?, 0, ?, ?)
230
+ ON CONFLICT(op_id) DO UPDATE SET
231
+ op_type = excluded.op_type,
232
+ payload = excluded.payload,
233
+ operation_revision = excluded.operation_revision,
234
+ status = 'ready',
235
+ claim_id = NULL,
236
+ next_at = excluded.next_at,
237
+ updated_at = excluded.updated_at
238
+ WHERE excluded.operation_revision > pending_ops.operation_revision`, opId, payload.type, encodeValue(payload), runRevision, now, now, now);
239
+ }
240
+ async function drainPendingOps(c) {
241
+ const deadline = Date.now() + OUTBOX_DRAIN_BUDGET_MS;
242
+ for (let processed = 0; processed < OUTBOX_BATCH; processed++) {
243
+ const now = Date.now();
244
+ const claimId = `claim_${ulid()}`;
245
+ const rows = await c.db.execute(`UPDATE pending_ops SET status = 'inflight', claim_id = ?,
246
+ next_at = ?, attempt = attempt + 1, updated_at = ?
247
+ WHERE op_id = (
248
+ SELECT op_id FROM pending_ops
249
+ WHERE (status = 'ready' AND next_at <= ?)
250
+ OR (status = 'inflight' AND next_at <= ?)
251
+ ORDER BY created_at ASC LIMIT 1
252
+ ) RETURNING op_id, payload, operation_revision, attempt`, claimId, now + OUTBOX_CLAIM_MS, now, now, now);
253
+ const row = rows[0];
254
+ if (!row)
255
+ return;
256
+ await armNextWake(c);
257
+ try {
258
+ const payload = decodeValue(row.payload);
259
+ if (!payload)
260
+ throw new Error("pending coordinator update is empty");
261
+ if (payload.type === "hook.confirm") {
262
+ const result = await withinDeadline(c.client().hookToken.getOrCreate([payload.token]).confirm(payload.token, payload.generation, payload.runId, payload.hookId), deadline);
263
+ if (!result.ok)
264
+ throw new Error("hook confirmation generation is stale");
265
+ }
266
+ else if (payload.type === "hook.release") {
267
+ await withinDeadline(c.client().hookToken.getOrCreate([payload.token]).release(payload.token, payload.generation, payload.runId, payload.hookId), deadline);
268
+ }
269
+ else {
270
+ await withinDeadline(c.client().coordinator.getOrCreate(["coordinator"]).update(payload), deadline);
271
+ }
272
+ await c.db.execute(`DELETE FROM pending_ops
273
+ WHERE op_id = ? AND claim_id = ? AND operation_revision = ?`, String(row.op_id), claimId, Number(row.operation_revision));
274
+ }
275
+ catch {
276
+ await c.db.execute(`UPDATE pending_ops SET status = 'ready', claim_id = NULL,
277
+ next_at = ?, updated_at = ? WHERE op_id = ? AND claim_id = ?`, now + Math.min(30_000, 1_000 * 2 ** Math.min(Number(row.attempt) - 1, 5)), now, String(row.op_id), claimId);
278
+ await armNextWake(c);
279
+ return;
280
+ }
281
+ }
282
+ // A full batch means there may be more work. Yield through the actor alarm.
283
+ await c.schedule.after(0, "wake");
284
+ }
285
+ function startPendingDrain(c) {
286
+ c.vars.pendingGeneration++;
287
+ if (c.vars.pendingDraining)
288
+ return;
289
+ const launch = () => {
290
+ const generation = c.vars.pendingGeneration;
291
+ c.vars.pendingDraining = true;
292
+ void c
293
+ .keepAwake(drainPendingOps(c).finally(() => {
294
+ c.vars.pendingDraining = false;
295
+ if (c.vars.pendingGeneration !== generation)
296
+ launch();
297
+ }))
298
+ .catch(() => { });
299
+ };
300
+ launch();
301
+ }
302
+ export const workflowRun = actor({
303
+ db: workflowDb,
304
+ events: { streamAppended },
305
+ createVars: createWorkflowVars,
306
+ onWake: async (c) => {
307
+ const ctx = asWorkflowContext(c);
308
+ await withSerializedAction(ctx, async () => {
309
+ await prearmRecovery(ctx);
310
+ ctx.vars.wakeArmedAt = null;
311
+ await cleanupExpiredStreams(ctx);
312
+ await drainPendingOps(ctx);
313
+ startDispatchLoop(ctx, ctx.key[0] ?? "__health");
314
+ await finishRecoveryArm(ctx);
315
+ });
316
+ },
317
+ actions: {
318
+ appendEvent: async (c, runIdOrNull, data, params, initialDispatch, hookSideEffects) => {
319
+ const wfc = asWorkflowContext(c);
320
+ return withRecoveryWriter(wfc, async () => {
321
+ const actorRunId = wfc.key[0];
322
+ if (!actorRunId)
323
+ throw new Error("workflowRun actor key is missing");
324
+ if (runIdOrNull != null)
325
+ assertRunKey(wfc, runIdOrNull);
326
+ const result = await withActorTransaction(wfc, async (c) => {
327
+ const runId = data.eventType === "run_created" && !runIdOrNull
328
+ ? actorRunId
329
+ : runIdOrNull;
330
+ if (!runId)
331
+ throw new Error("runId is required");
332
+ const specVersion = data.specVersion ?? SPEC_VERSION_CURRENT;
333
+ const now = Date.now();
334
+ const resolveData = params?.resolveData ?? "all";
335
+ let run;
336
+ let step;
337
+ let hook;
338
+ let hooksToDelete = [];
339
+ let wait;
340
+ let stepCreated;
341
+ const status = data.eventType === "run_created"
342
+ ? null
343
+ : await currentRunStatus(c, runId);
344
+ if (status && terminalRuns.has(status)) {
345
+ throw new EntityConflictError(`Cannot modify run in terminal state "${status}"`);
346
+ }
347
+ if (status == null &&
348
+ data.eventType !== "run_created" &&
349
+ data.eventType !== "run_started") {
350
+ throw new WorkflowRunNotFoundError(runId);
351
+ }
352
+ if (data.eventType === "run_created") {
353
+ await c.db.execute(`
354
+ INSERT OR IGNORE INTO workflow_runs (
355
+ run_id, status, deployment_id, workflow_name, spec_version,
356
+ execution_context, attributes, input, created_at, updated_at
357
+ )
358
+ VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)
359
+ `, runId, data.eventData.deploymentId, data.eventData.workflowName, specVersion, encodeValue(data.eventData.executionContext), encodeValue(data.eventData.attributes ?? {}), encodeValue(data.eventData.input), now, now);
360
+ run = await requireRun(c, runId);
361
+ if (initialDispatch) {
362
+ await insertDispatch(c, initialDispatch.initial.messageId, initialDispatch.queueName, initialDispatch.runtimeUrl, initialDispatch.initial.body, initialDispatch.headers, 0, initialDispatch.initial.idempotencyKey);
363
+ }
364
+ }
365
+ if (data.eventType === "run_started") {
366
+ // Resilient start (SPEC §3.2): if run_created was lost, recreate the
367
+ // run from the creation fields carried on run_started when present.
368
+ const startData = data.eventData;
369
+ if (startData?.deploymentId && startData.workflowName) {
370
+ await c.db.execute(`
371
+ INSERT OR IGNORE INTO workflow_runs (
372
+ run_id, status, deployment_id, workflow_name, spec_version,
373
+ execution_context, attributes, input, created_at, updated_at
374
+ )
375
+ VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)
376
+ `, runId, startData.deploymentId, startData.workflowName, specVersion, encodeValue(startData.executionContext), encodeValue(startData.attributes ?? {}), encodeValue(startData.input), now, now);
377
+ }
378
+ await c.db.execute(`
379
+ UPDATE workflow_runs
380
+ SET status = 'running', started_at = COALESCE(started_at, ?), updated_at = ?
381
+ WHERE run_id = ?
382
+ `, now, now, runId);
383
+ run = await requireRun(c, runId);
384
+ }
385
+ if (data.eventType === "run_completed") {
386
+ hooksToDelete = (await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)).map((row) => ({
387
+ hook: rowToHook(row),
388
+ generation: row.token_generation == null ? null : Number(row.token_generation),
389
+ }));
390
+ await c.db.execute(`
391
+ UPDATE workflow_runs
392
+ SET status = 'completed', output = ?, completed_at = ?, updated_at = ?
393
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
394
+ `, encodeValue(data.eventData.output), now, now, runId);
395
+ run = await requireRun(c, runId);
396
+ await releaseRunHooks(c, runId);
397
+ }
398
+ if (data.eventType === "run_failed") {
399
+ hooksToDelete = (await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)).map((row) => ({
400
+ hook: rowToHook(row),
401
+ generation: row.token_generation == null ? null : Number(row.token_generation),
402
+ }));
403
+ const error = errorDetails(data.eventData.error);
404
+ await c.db.execute(`
405
+ UPDATE workflow_runs
406
+ SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
407
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
408
+ `, encodeValue({
409
+ message: error.message,
410
+ stack: error.stack,
411
+ code: data.eventData.errorCode,
412
+ }), now, now, runId);
413
+ run = await requireRun(c, runId);
414
+ await releaseRunHooks(c, runId);
415
+ }
416
+ if (data.eventType === "run_cancelled") {
417
+ hooksToDelete = (await c.db.execute("SELECT * FROM workflow_hooks WHERE run_id = ?", runId)).map((row) => ({
418
+ hook: rowToHook(row),
419
+ generation: row.token_generation == null ? null : Number(row.token_generation),
420
+ }));
421
+ await c.db.execute(`
422
+ UPDATE workflow_runs
423
+ SET status = 'cancelled', completed_at = ?, updated_at = ?
424
+ WHERE run_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
425
+ `, now, now, runId);
426
+ run = await requireRun(c, runId);
427
+ await releaseRunHooks(c, runId);
428
+ }
429
+ if (data.eventType === "step_created") {
430
+ await c.db.execute(`
431
+ INSERT OR IGNORE INTO workflow_steps (
432
+ step_id, run_id, step_name, status, input, attempt, created_at, updated_at, spec_version
433
+ )
434
+ VALUES (?, ?, ?, 'pending', ?, 0, ?, ?, ?)
435
+ `, data.correlationId, runId, data.eventData.stepName, encodeValue(data.eventData.input), now, now, specVersion);
436
+ const row = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
437
+ if (row)
438
+ step = rowToStep(row);
439
+ }
440
+ if (data.eventType === "step_started") {
441
+ let existing = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
442
+ const lazyData = data.eventData?.stepName != null &&
443
+ data.eventData.input !== undefined
444
+ ? {
445
+ stepName: data.eventData.stepName,
446
+ input: data.eventData.input,
447
+ }
448
+ : null;
449
+ if (!existing && lazyData) {
450
+ const inserted = await c.db.execute(`
451
+ INSERT OR IGNORE INTO workflow_steps (
452
+ step_id, run_id, step_name, status, input, attempt, created_at, updated_at, spec_version
453
+ )
454
+ VALUES (?, ?, ?, 'pending', ?, 0, ?, ?, ?)
455
+ RETURNING step_id
456
+ `, data.correlationId, runId, lazyData.stepName, encodeValue(lazyData.input), now, now, specVersion);
457
+ if (inserted.length === 0) {
458
+ throw new EntityConflictError(`Step "${data.correlationId}" already exists`);
459
+ }
460
+ stepCreated = true;
461
+ // Lazy step creation must be visible to event-log replay exactly as
462
+ // eager creation is. Both rows live in this actor transaction, so the
463
+ // synthetic creation and the materialized step cannot diverge.
464
+ await insertEvent(c, runId, `wevt_${ulid()}`, {
465
+ eventType: "step_created",
466
+ correlationId: data.correlationId,
467
+ eventData: {
468
+ stepName: lazyData.stepName,
469
+ input: lazyData.input,
470
+ },
471
+ }, specVersion, now);
472
+ existing = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
473
+ }
474
+ else if (existing && lazyData) {
475
+ if (String(existing.step_name) !== lazyData.stepName ||
476
+ !isDeepStrictEqual(decodeValue(existing.input), lazyData.input)) {
477
+ throw new EntityConflictError(`Step "${data.correlationId}" already exists with different data`);
478
+ }
479
+ }
480
+ if (!existing)
481
+ throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
482
+ if (String(existing.status) === "running" ||
483
+ terminalSteps.has(String(existing.status))) {
484
+ // Vercel may redeliver an accepted step start. Returning the existing
485
+ // step keeps delivery idempotent without incrementing its attempt.
486
+ step = rowToStep(existing);
487
+ }
488
+ else {
489
+ if (existing.retry_after != null &&
490
+ Number(existing.retry_after) > Date.now()) {
491
+ throw new TooEarlyError(`Cannot start step "${data.correlationId}": retryAfter timestamp has not been reached yet`, {
492
+ retryAfter: Math.ceil((Number(existing.retry_after) - Date.now()) / 1000),
493
+ });
494
+ }
495
+ await c.db.execute(`
496
+ UPDATE workflow_steps
497
+ SET status = 'running',
498
+ attempt = attempt + 1,
499
+ started_at = COALESCE(started_at, ?),
500
+ retry_after = NULL,
501
+ updated_at = ?
502
+ WHERE step_id = ? AND status NOT IN ('running', 'completed', 'failed', 'cancelled')
503
+ `, now, now, data.correlationId);
504
+ step = rowToStep(await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId));
505
+ }
506
+ }
507
+ if (data.eventType === "step_completed") {
508
+ const updated = await c.db.execute(`
509
+ UPDATE workflow_steps
510
+ SET status = 'completed', output = ?, completed_at = ?, updated_at = ?
511
+ WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
512
+ RETURNING *
513
+ `, encodeValue(data.eventData.result), now, now, data.correlationId);
514
+ if (updated[0]) {
515
+ step = rowToStep(updated[0]);
516
+ }
517
+ else {
518
+ const existing = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
519
+ if (!existing)
520
+ throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
521
+ if (terminalSteps.has(String(existing.status))) {
522
+ throw new EntityConflictError(`Cannot modify step in terminal state "${existing.status}"`);
523
+ }
524
+ step = rowToStep(existing);
525
+ }
526
+ }
527
+ if (data.eventType === "step_failed") {
528
+ const error = errorDetails(data.eventData.error);
529
+ const updated = await c.db.execute(`
530
+ UPDATE workflow_steps
531
+ SET status = 'failed', error = ?, completed_at = ?, updated_at = ?
532
+ WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
533
+ RETURNING *
534
+ `, encodeValue({ message: error.message, stack: error.stack }), now, now, data.correlationId);
535
+ if (updated[0]) {
536
+ step = rowToStep(updated[0]);
537
+ }
538
+ else {
539
+ const existing = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
540
+ if (!existing)
541
+ throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
542
+ if (terminalSteps.has(String(existing.status))) {
543
+ throw new EntityConflictError(`Cannot modify step in terminal state "${existing.status}"`);
544
+ }
545
+ step = rowToStep(existing);
546
+ }
547
+ }
548
+ if (data.eventType === "step_retrying") {
549
+ const error = errorDetails(data.eventData.error);
550
+ const updated = await c.db.execute(`
551
+ UPDATE workflow_steps
552
+ SET status = 'pending', error = ?, retry_after = ?, updated_at = ?
553
+ WHERE step_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')
554
+ RETURNING *
555
+ `, encodeValue({ message: error.message, stack: error.stack }), toMs(data.eventData.retryAfter), now, data.correlationId);
556
+ if (updated[0]) {
557
+ step = rowToStep(updated[0]);
558
+ }
559
+ else {
560
+ const existing = await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", data.correlationId);
561
+ if (!existing)
562
+ throw new WorkflowWorldError(`Step "${data.correlationId}" not found`);
563
+ if (terminalSteps.has(String(existing.status))) {
564
+ throw new EntityConflictError(`Cannot modify step in terminal state "${existing.status}"`);
565
+ }
566
+ step = rowToStep(existing);
567
+ }
568
+ }
569
+ if (data.eventType === "hook_created") {
570
+ const existingCreation = await one(c, `SELECT 1 FROM workflow_events
571
+ WHERE event_type = 'hook_created' AND correlation_id = ?`, data.correlationId);
572
+ if (!existingCreation)
573
+ await c.db.execute(`
574
+ INSERT OR IGNORE INTO workflow_hooks (
575
+ hook_id, run_id, token, token_generation, owner_id, project_id, environment,
576
+ metadata, created_at, spec_version, is_webhook
577
+ )
578
+ VALUES (?, ?, ?, ?, '', '', '', ?, ?, ?, ?)
579
+ `, data.correlationId, runId, data.eventData.token, hookSideEffects?.confirm?.generation ?? null, encodeValue(data.eventData.metadata), now, specVersion, 1);
580
+ const row = await one(c, "SELECT * FROM workflow_hooks WHERE hook_id = ?", data.correlationId);
581
+ if (existingCreation && !row) {
582
+ throw new EntityConflictError(`Hook "${data.correlationId}" was already disposed`);
583
+ }
584
+ if (row && String(row.token) !== data.eventData.token) {
585
+ throw new EntityConflictError(`Hook "${data.correlationId}" already uses a different token`);
586
+ }
587
+ if (row)
588
+ hook = rowToHook(row);
589
+ }
590
+ if (data.eventType === "hook_disposed") {
591
+ const existingHook = await one(c, "SELECT * FROM workflow_hooks WHERE hook_id = ?", data.correlationId);
592
+ if (existingHook) {
593
+ hooksToDelete.push({
594
+ hook: rowToHook(existingHook),
595
+ generation: existingHook.token_generation == null
596
+ ? null
597
+ : Number(existingHook.token_generation),
598
+ });
599
+ }
600
+ await c.db.execute("DELETE FROM workflow_hooks WHERE hook_id = ?", data.correlationId);
601
+ }
602
+ if (data.eventType === "wait_created") {
603
+ const waitId = `${runId}-${data.correlationId}`;
604
+ const resumeAt = toMs(data.eventData.resumeAt);
605
+ await c.db.execute(`
606
+ INSERT OR IGNORE INTO workflow_waits (
607
+ wait_id, run_id, status, resume_at, created_at, updated_at, spec_version
608
+ )
609
+ VALUES (?, ?, 'waiting', ?, ?, ?, ?)
610
+ RETURNING wait_id
611
+ `, waitId, runId, resumeAt, now, now, specVersion);
612
+ wait = rowToWait(await one(c, "SELECT * FROM workflow_waits WHERE wait_id = ?", waitId));
613
+ }
614
+ if (data.eventType === "wait_completed") {
615
+ const waitId = `${runId}-${data.correlationId}`;
616
+ await c.db.execute(`
617
+ UPDATE workflow_waits
618
+ SET status = 'completed', completed_at = ?, updated_at = ?
619
+ WHERE wait_id = ? AND status = 'waiting'
620
+ `, now, now, waitId);
621
+ const row = await one(c, "SELECT * FROM workflow_waits WHERE wait_id = ?", waitId);
622
+ if (row)
623
+ wait = rowToWait(row);
624
+ }
625
+ const event = await insertEvent(c, runId, `wevt_${ulid()}`, data, specVersion, now);
626
+ await c.db.execute("UPDATE workflow_runs SET run_revision = run_revision + 1 WHERE run_id = ?", runId);
627
+ const revisionRow = await one(c, "SELECT run_revision FROM workflow_runs WHERE run_id = ?", runId);
628
+ const runRevision = Number(revisionRow?.run_revision ?? 0);
629
+ if (run) {
630
+ run = await requireRun(c, runId);
631
+ await addPendingOp(c, "coordinator:run", runRevision, {
632
+ type: "run.upsert",
633
+ run,
634
+ runRevision,
635
+ });
636
+ }
637
+ const indexCorrelationId = "correlationId" in data ? data.correlationId : undefined;
638
+ if (indexCorrelationId != null &&
639
+ (data.eventType === "step_created" ||
640
+ stepCreated === true ||
641
+ data.eventType === "hook_created" ||
642
+ data.eventType === "wait_created")) {
643
+ await addPendingOp(c, `coordinator:correlation:${indexCorrelationId}`, runRevision, {
644
+ type: "correlation.put",
645
+ correlationId: indexCorrelationId,
646
+ runId,
647
+ kind: data.eventType === "step_created" || stepCreated === true
648
+ ? "step"
649
+ : data.eventType === "hook_created"
650
+ ? "hook"
651
+ : "wait",
652
+ runRevision,
653
+ });
654
+ }
655
+ if (data.eventType === "hook_created" && hook) {
656
+ await addPendingOp(c, `coordinator:hook:${hook.hookId}`, runRevision, {
657
+ type: "hook.upsert",
658
+ hookId: hook.hookId,
659
+ runId,
660
+ token: hook.token,
661
+ runRevision,
662
+ createdAt: hook.createdAt,
663
+ updatedAt: hook.createdAt,
664
+ });
665
+ }
666
+ for (const { hook: deletedHook, generation } of hooksToDelete) {
667
+ await addPendingOp(c, `coordinator:hook:${deletedHook.hookId}`, runRevision, {
668
+ type: "hook.delete",
669
+ hookId: deletedHook.hookId,
670
+ runId,
671
+ token: deletedHook.token,
672
+ runRevision,
673
+ createdAt: deletedHook.createdAt,
674
+ updatedAt: Date.now(),
675
+ });
676
+ await addPendingOp(c, `coordinator:correlation:${deletedHook.hookId}`, runRevision, {
677
+ type: "correlation.delete",
678
+ correlationId: deletedHook.hookId,
679
+ runId,
680
+ kind: "hook",
681
+ runRevision,
682
+ });
683
+ if (generation != null) {
684
+ await addPendingOp(c, `hook-token:${deletedHook.token}`, runRevision, {
685
+ type: "hook.release",
686
+ token: deletedHook.token,
687
+ hookId: deletedHook.hookId,
688
+ generation,
689
+ runId,
690
+ });
691
+ }
692
+ }
693
+ if (hookSideEffects?.confirm) {
694
+ await addPendingOp(c, `hook-token:${hookSideEffects.confirm.token}`, runRevision, {
695
+ type: "hook.confirm",
696
+ runId,
697
+ ...hookSideEffects.confirm,
698
+ });
699
+ }
700
+ let events;
701
+ let cursor;
702
+ let hasMore;
703
+ if (data.eventType === "run_started" && run) {
704
+ events = (await listEventsForRun(c, runId)).map((item) => stripEventDataRefs(item, resolveData));
705
+ cursor = events.at(-1)?.eventId ?? null;
706
+ hasMore = false;
707
+ }
708
+ return {
709
+ event: stripEventDataRefs(event, resolveData),
710
+ run: run && filterData(run, resolveData),
711
+ step: step && filterData(step, resolveData),
712
+ hook: hook && filterHookData(hook, resolveData),
713
+ wait,
714
+ stepCreated,
715
+ events,
716
+ cursor,
717
+ hasMore,
718
+ };
719
+ });
720
+ if (data.eventType === "run_created" && initialDispatch) {
721
+ startDispatchLoop(wfc, actorRunId);
722
+ }
723
+ await wfc.schedule.after(0, "wake");
724
+ startPendingDrain(wfc);
725
+ return result;
726
+ });
727
+ },
728
+ getRun: async (c, runId, params) => {
729
+ assertRunKey(c, runId);
730
+ return filterData(await requireRun(c, runId), params?.resolveData);
731
+ },
732
+ getEvent: async (c, runId, eventId, params) => {
733
+ assertRunKey(c, runId);
734
+ const row = await one(c, `
735
+ SELECT * FROM workflow_events
736
+ WHERE run_id = ? AND event_id = ?
737
+ `, runId, eventId);
738
+ if (!row)
739
+ throw new WorkflowWorldError(`Event not found: ${eventId}`);
740
+ return stripEventDataRefs(rowToEvent(row), params?.resolveData ?? "all");
741
+ },
742
+ listEvents: async (c, params) => {
743
+ assertRunKey(c, params.runId);
744
+ const limit = params.pagination?.limit ?? 100;
745
+ const cursor = params.pagination?.cursor;
746
+ const desc = params.pagination?.sortOrder === "desc";
747
+ const rows = await c.db.execute(`
748
+ SELECT * FROM workflow_events
749
+ WHERE run_id = ? AND (? IS NULL OR event_id ${desc ? "<" : ">"} ?)
750
+ ORDER BY event_id ${desc ? "DESC" : "ASC"}
751
+ LIMIT ?
752
+ `, params.runId, cursor ?? null, cursor ?? null, limit + 1);
753
+ const values = rows.slice(0, limit);
754
+ return {
755
+ data: values.map((row) => stripEventDataRefs(rowToEvent(row), params.resolveData ?? "all")),
756
+ cursor: values.at(-1)?.event_id?.toString() ?? null,
757
+ hasMore: rows.length > limit,
758
+ };
759
+ },
760
+ listEventsByCorrelationId: async (c, params) => {
761
+ const limit = params.pagination?.limit ?? 100;
762
+ const cursor = params.pagination?.cursor;
763
+ const rows = await c.db.execute(`
764
+ SELECT * FROM workflow_events
765
+ WHERE correlation_id = ? AND (? IS NULL OR event_id > ?)
766
+ ORDER BY event_id ASC
767
+ LIMIT ?
768
+ `, params.correlationId, cursor ?? null, cursor ?? null, limit + 1);
769
+ const values = rows.slice(0, limit);
770
+ return {
771
+ data: values.map((row) => stripEventDataRefs(rowToEvent(row), params.resolveData ?? "all")),
772
+ cursor: values.at(-1)?.event_id?.toString() ?? null,
773
+ hasMore: rows.length > limit,
774
+ };
775
+ },
776
+ getStep: async (c, runId, stepId, params) => {
777
+ if (runId)
778
+ assertRunKey(c, runId);
779
+ const row = runId
780
+ ? await one(c, "SELECT * FROM workflow_steps WHERE run_id = ? AND step_id = ?", runId, stepId)
781
+ : await one(c, "SELECT * FROM workflow_steps WHERE step_id = ?", stepId);
782
+ if (!row)
783
+ throw new WorkflowWorldError(`Step not found: ${stepId}`);
784
+ return filterData(rowToStep(row), params?.resolveData);
785
+ },
786
+ listSteps: async (c, params) => {
787
+ assertRunKey(c, params.runId);
788
+ const limit = params.pagination?.limit ?? 20;
789
+ const cursor = params.pagination?.cursor;
790
+ const rows = await c.db.execute(`
791
+ SELECT * FROM workflow_steps
792
+ WHERE run_id = ? AND (? IS NULL OR step_id < ?)
793
+ ORDER BY step_id DESC
794
+ LIMIT ?
795
+ `, params.runId, cursor ?? null, cursor ?? null, limit + 1);
796
+ const values = rows.slice(0, limit);
797
+ return {
798
+ data: values.map((row) => filterData(rowToStep(row), params.resolveData)),
799
+ cursor: values.at(-1)?.step_id?.toString() ?? null,
800
+ hasMore: rows.length > limit,
801
+ };
802
+ },
803
+ getHook: async (c, hookId, params) => {
804
+ const row = await one(c, "SELECT * FROM workflow_hooks WHERE hook_id = ?", hookId);
805
+ if (!row)
806
+ throw new HookNotFoundError(hookId);
807
+ return filterHookData(rowToHook(row), params?.resolveData);
808
+ },
809
+ ownsHook: async (c, hookId, token) => {
810
+ const runId = c.key[0];
811
+ if (!runId)
812
+ throw new Error("workflowRun actor key is missing");
813
+ const row = await one(c, `
814
+ SELECT 1 AS owns_hook
815
+ FROM workflow_hooks
816
+ WHERE run_id = ? AND hook_id = ? AND token = ?
817
+ `, runId, hookId, token);
818
+ return row != null;
819
+ },
820
+ listHooks: async (c, params) => {
821
+ if (params.runId)
822
+ assertRunKey(c, params.runId);
823
+ const limit = params.pagination?.limit ?? 100;
824
+ const cursor = params.pagination?.cursor;
825
+ const rows = await c.db.execute(`
826
+ SELECT * FROM workflow_hooks
827
+ WHERE (? IS NULL OR run_id = ?) AND (? IS NULL OR hook_id > ?)
828
+ ORDER BY hook_id ASC
829
+ LIMIT ?
830
+ `, params.runId ?? null, params.runId ?? null, cursor ?? null, cursor ?? null, limit + 1);
831
+ const values = rows.slice(0, limit);
832
+ return {
833
+ data: values.map((row) => filterHookData(rowToHook(row), params.resolveData)),
834
+ cursor: values.at(-1)?.hook_id?.toString() ?? null,
835
+ hasMore: rows.length > limit,
836
+ };
837
+ },
838
+ wake: async (c) => {
839
+ const ctx = asWorkflowContext(c);
840
+ await withSerializedAction(ctx, async () => {
841
+ await prearmRecovery(ctx);
842
+ ctx.vars.wakeArmedAt = null;
843
+ await cleanupExpiredStreams(ctx);
844
+ await drainPendingOps(ctx);
845
+ startDispatchLoop(ctx, ctx.key[0] ?? "__health");
846
+ await finishRecoveryArm(ctx);
847
+ });
848
+ },
849
+ enqueue: (c, partition, messageId, queueName, runtimeUrl, body, headers, delaySeconds, idempotencyKey) => {
850
+ const ctx = asWorkflowContext(c);
851
+ return withRecoveryWriter(ctx, () => enqueueDispatch(ctx, partition, messageId, queueName, runtimeUrl, body, headers, delaySeconds, idempotencyKey));
852
+ },
853
+ inspectQueue: (c) => inspectQueue(c),
854
+ forceStaleInflightForTesting: (c, partition, messageId, ageMs) => forceStaleInflightForTesting(c, partition, messageId, ageMs),
855
+ writeStream: async (c, streamId, runId, chunk, eof) => {
856
+ const ctx = asWorkflowContext(c);
857
+ return withRecoveryWriter(ctx, async () => {
858
+ const result = await writeStream(ctx, streamId, runId, chunk, eof);
859
+ if (eof && STREAM_TTL_MS > 0) {
860
+ await ctx.schedule.at(Date.now() + STREAM_TTL_MS, "wake");
861
+ }
862
+ return result;
863
+ });
864
+ },
865
+ getStreamChunks: (c, streamId, runId, startIndex, limit) => getStreamChunks(c, streamId, runId, startIndex, limit),
866
+ getStreamInfo: (c, streamId, runId) => getStreamInfo(c, streamId, runId),
867
+ listStreams: (c, runId) => listStreams(c, runId),
868
+ expireClosedStreamForTesting: (c, streamId, runId) => expireClosedStreamForTesting(c, streamId, runId),
869
+ },
870
+ options: { sleepTimeout: 1000 },
871
+ });
872
+ //# sourceMappingURL=workflow-run.js.map