workerflow 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -4,15 +4,11 @@ import type { Json } from "./json";
4
4
  import mig000 from "./migrations/0000_initial";
5
5
  import type { Brand } from "./brand";
6
6
 
7
- export abstract class WorkflowRuntime<
8
- TInput extends Json | undefined = Json | undefined,
9
- TVersion extends string = string
10
- > extends DurableObject {
7
+ export abstract class WorkflowRuntime<TInput extends Json | undefined = Json | undefined> extends DurableObject {
11
8
  private static readonly MIGRATIONS = [mig000];
12
9
  private readonly sql: SqlStorage;
13
10
  #status: WorkflowStatus;
14
11
  #isRunLoopActive: boolean = false;
15
- #definitionVersion: TVersion | undefined;
16
12
  #definitionInput: TInput | undefined;
17
13
 
18
14
  /**
@@ -46,24 +42,19 @@ export abstract class WorkflowRuntime<
46
42
  console.error("Database migration version is ahead of the codebase. Please check your migrations.");
47
43
  }
48
44
 
49
- const [metadata] = this.sql
50
- .exec<WorkflowMetadata_Row<TVersion>>("SELECT * FROM workflow_metadata WHERE id = 1")
51
- .toArray();
45
+ const [metadata] = this.sql.exec<WorkflowMetadata_Row>("SELECT * FROM workflow_metadata WHERE id = 1").toArray();
52
46
  if (metadata === undefined) {
53
47
  this.sql.exec("INSERT INTO workflow_metadata (id, status) VALUES (1, ?)", "pending");
54
48
  this.sql.exec("INSERT INTO workflow_events (type) VALUES (?)", "created");
55
49
  this.#status = "pending";
56
50
  } else {
57
51
  this.#status = metadata.status;
58
- this.#definitionVersion = metadata.definition_version === null ? undefined : metadata.definition_version;
59
52
  this.#definitionInput =
60
53
  metadata.definition_input === null ? undefined : (JSON.parse(metadata.definition_input) as TInput);
61
54
  }
62
55
  }
63
56
 
64
- protected abstract getDefinition(
65
- version: TVersion
66
- ): (options: {
57
+ protected abstract readonly definition: (options: {
67
58
  props: { requestId: string; runtimeInstanceId: string; input: TInput };
68
59
  }) => Fetcher<WorkflowDefinition<TInput>>;
69
60
 
@@ -117,19 +108,61 @@ export abstract class WorkflowRuntime<
117
108
  * @returns An array containing the formatted steps for all steps in the
118
109
  * workflow.
119
110
  */
120
- getSteps_experimental(): Step[] {
111
+ getSteps_experimental(): Array<(RunStep & { attempts: RunStepAttempt[] }) | SleepStep | WaitStep> {
121
112
  const steps = this.sql.exec<Step_Row>("SELECT * FROM steps ORDER BY created_at ASC").toArray();
122
- return steps.map((step) => formatStep(step));
113
+ return steps.map((step) => {
114
+ if (step.type === "run") {
115
+ const attempts = this.sql
116
+ .exec<RunStepAttempt_Row>(
117
+ `SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC, id ASC`,
118
+ step.id
119
+ )
120
+ .toArray();
121
+ return {
122
+ ...formatRunStep(step),
123
+ attempts: attempts.map((attempt) => formatRunStepAttempt(attempt))
124
+ };
125
+ }
126
+ if (step.type === "sleep") {
127
+ return formatSleepStep(step);
128
+ } else if (step.type === "wait") {
129
+ if (step.state === "satisfied") {
130
+ return formatSatisfiedWaitStep(step, this.getInboundEventForWaitStep(step.id).payload);
131
+ } else if (step.state === "timed_out") {
132
+ return formatTimedOutWaitStep(step);
133
+ } else {
134
+ return formatWaitingWaitStep(step);
135
+ }
136
+ } else {
137
+ throw new Error("Unexpected step type. Expected 'run', 'sleep', or 'wait'.");
138
+ }
139
+ });
123
140
  }
124
141
 
125
142
  /**
126
- * Retrieves all durable step events in the workflow, ordered by recording time.
143
+ * Loads the `inbound_events` row whose `claimed_by` is this wait step (`waitStepId`).
127
144
  *
128
- * @returns Formatted `step_events` rows for the workflow instance.
145
+ * @throws If no such row exists (storage invariant broken or the step is not in a satisfied state with a claim).
129
146
  */
130
- getStepEvents_experimental(): StepEvent[] {
131
- const events = this.sql.exec<StepEventRow>("SELECT * FROM step_events ORDER BY recorded_at ASC");
132
- return events.toArray().map((row) => formatStepEvent(row));
147
+ private getInboundEventForWaitStep<T extends Json | undefined = Json | undefined>(
148
+ stepId: WaitStepId
149
+ ): InboundEvent<T> {
150
+ const [row] = this.sql
151
+ .exec<ClaimedInboundEvent_Row>(`SELECT * FROM inbound_events WHERE claimed_by = ? LIMIT 1`, stepId)
152
+ .toArray();
153
+ if (row === undefined) {
154
+ throw new Error(
155
+ `Wait step '${stepId}' is satisfied in durable state but no inbound_events row is claimed by this step.`
156
+ );
157
+ }
158
+ return {
159
+ id: row.id,
160
+ eventName: row.event_name,
161
+ payload: (row.payload === null ? undefined : JSON.parse(row.payload)) as T,
162
+ createdAt: new Date(row.created_at),
163
+ claimedBy: row.claimed_by,
164
+ claimedAt: new Date(row.claimed_at)
165
+ };
133
166
  }
134
167
 
135
168
  /**
@@ -157,7 +190,9 @@ export abstract class WorkflowRuntime<
157
190
  return;
158
191
  }
159
192
 
160
- const serializedPayload = payload !== undefined ? JSON.stringify(payload) : null;
193
+ // SQL NULL encodes `undefined` (no payload); raw JSON.stringify for everything else
194
+ // (including JSON null, which becomes the TEXT literal 'null').
195
+ const serializedPayload = payload === undefined ? null : JSON.stringify(payload);
161
196
 
162
197
  // If the workflow is paused, queue the event but do not satisfy any wait step or call run().
163
198
  // The event will be picked up when the workflow is resumed and execution hits getOrCreateWaitStep.
@@ -184,34 +219,24 @@ export abstract class WorkflowRuntime<
184
219
  .toArray();
185
220
 
186
221
  if (step !== undefined) {
187
- this.sql.exec(
188
- `UPDATE steps
222
+ this.ctx.storage.transactionSync(() => {
223
+ this.sql.exec(
224
+ `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at)
225
+ VALUES (?, ?, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER))`,
226
+ event,
227
+ serializedPayload,
228
+ step.id
229
+ );
230
+ this.sql.exec(
231
+ `UPDATE steps
189
232
  SET state = 'satisfied',
190
- payload = ?,
191
- resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER),
192
- timeout_at = NULL
233
+ resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
193
234
  WHERE id = ?
194
235
  AND type = 'wait'
195
236
  AND state = 'waiting'`,
196
- serializedPayload,
197
- step.id
198
- );
199
-
200
- this.sql.exec(
201
- `INSERT INTO step_events (step_id, type, payload)
202
- VALUES (?, ?, ?)`,
203
- step.id,
204
- "wait_satisfied",
205
- serializedPayload
206
- );
207
-
208
- this.sql.exec(
209
- `INSERT INTO inbound_events (event_name, payload, claimed_by, claimed_at)
210
- VALUES (?, ?, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER))`,
211
- event,
212
- serializedPayload,
213
- step.id
214
- );
237
+ step.id
238
+ );
239
+ });
215
240
 
216
241
  await this.run();
217
242
  } else {
@@ -280,56 +305,49 @@ export abstract class WorkflowRuntime<
280
305
 
281
306
  // Schedule another safety alarm if the run loop is still active.
282
307
  if (this.#isRunLoopActive) {
283
- await this.ctx.storage.setAlarm(Date.now() + 30_000 * 60); // 30 minutes
308
+ await this.ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000); // 30 minutes
284
309
  } else {
285
310
  await this.run();
286
311
  }
287
312
  }
288
313
 
289
314
  /**
290
- * Creates a new workflow instance and pins the definition version. If the workflow is in a terminal state, it will
291
- * return early. Otherwise, it will pin the definition version and set the input. If the definition version is already
292
- * pinned to a different version, it will throw an error.
315
+ * Creates a new workflow instance and pins the input. If the workflow is in a terminal state or paused, it will
316
+ * return early. Otherwise, it will pin the input the first time the instance is initialized and start execution.
293
317
  *
294
- * @param options.definitionVersion - The version of the definition to pin to the workflow instance. This will be used
295
- * to resolve the workflow definition from the `getDefinition` hook.
296
- * @param options.input - The input to the workflow instance. This will be passed to the workflow definition as the
297
- * `input` property.
318
+ * @param input - The input to the workflow instance. This will be passed to the workflow definition as the `input`
319
+ * property.
298
320
  */
299
- public async create(options: { definitionVersion: TVersion; input?: TInput }): Promise<void> {
321
+ public async create(...args: undefined extends TInput ? [input?: TInput] : [input: TInput]): Promise<void> {
322
+ const input = args[0];
300
323
  if (this.isTerminalStatus(this.#status)) return;
301
324
  if (this.#status === "paused") return;
302
325
 
303
- const version = options.definitionVersion;
304
326
  let metadata = this.sql
305
- .exec<Pick<WorkflowMetadata_Row<TVersion>, "definition_version" | "definition_input">>(
306
- "SELECT definition_version, definition_input FROM workflow_metadata WHERE id = 1"
327
+ .exec<Pick<WorkflowMetadata_Row, "status" | "definition_input">>(
328
+ "SELECT status, definition_input FROM workflow_metadata WHERE id = 1"
307
329
  )
308
330
  .one();
309
331
 
310
- if (metadata.definition_version !== null && metadata.definition_version !== version) {
311
- throw new Error(
312
- `Workflow definition version is already pinned to '${metadata.definition_version}' and cannot be changed to '${version}'.`
313
- );
314
- }
315
-
316
- // If the workflow is not yet pinned to a definition version, we pin it to the new version and set the input.
317
- if (metadata.definition_version === null) {
332
+ // If the workflow is not yet initialized, pin the input. `undefined` is encoded as SQL NULL.
333
+ if (metadata.status === "pending") {
318
334
  metadata = this.sql
319
- .exec<Pick<WorkflowMetadata_Row<TVersion>, "definition_version" | "definition_input">>(
335
+ .exec<Pick<WorkflowMetadata_Row, "status" | "definition_input">>(
320
336
  `UPDATE workflow_metadata
321
- SET definition_version = ?,
337
+ SET status = 'initialized',
322
338
  definition_input = ?,
323
339
  updated_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
324
- WHERE id = 1 RETURNING definition_version, definition_input`,
325
- version,
326
- options.input ? JSON.stringify(options.input) : null
340
+ WHERE id = 1
341
+ AND status = 'pending'
342
+ RETURNING status, definition_input`,
343
+ input === undefined ? null : JSON.stringify(input)
327
344
  )
328
345
  .one();
329
346
  }
330
347
 
331
- this.#definitionVersion = version;
332
- this.#definitionInput = metadata.definition_input ? (JSON.parse(metadata.definition_input) as TInput) : undefined;
348
+ this.#status = metadata.status;
349
+ this.#definitionInput =
350
+ metadata.definition_input === null ? undefined : (JSON.parse(metadata.definition_input) as TInput);
333
351
 
334
352
  await this.run();
335
353
  }
@@ -338,11 +356,10 @@ export abstract class WorkflowRuntime<
338
356
  if (this.isTerminalStatus(this.#status)) return;
339
357
  if (this.#status === "paused") return;
340
358
 
341
- if (this.#definitionVersion === undefined) return;
359
+ if (this.#status === "pending") return;
342
360
 
343
361
  if (this.#status !== "running") {
344
362
  this.#setStatus({ type: "running" });
345
- this.#status = "running";
346
363
 
347
364
  if (this.onStatusChange_experimental !== undefined) {
348
365
  await this.onStatusChange_experimental("running");
@@ -352,7 +369,7 @@ export abstract class WorkflowRuntime<
352
369
  if (this.#isRunLoopActive) return;
353
370
 
354
371
  const requestId = crypto.randomUUID();
355
- const context = new WorkflowRuntimeContext(this.ctx.storage, { requestId });
372
+ const context = new WorkflowRuntimeContext(this.ctx.storage);
356
373
 
357
374
  this.#isRunLoopActive = true;
358
375
 
@@ -378,15 +395,11 @@ export abstract class WorkflowRuntime<
378
395
  }
379
396
 
380
397
  try {
381
- const version = this.#definitionVersion;
382
- if (version === undefined) {
383
- throw new Error(
384
- "Workflow definition version has not been initialized. Call 'start()' before running the workflow."
385
- );
398
+ if (this.#status === "pending") {
399
+ throw new Error("Workflow input has not been initialized. Call 'create()' before running the workflow.");
386
400
  }
387
401
 
388
- const definition = this.getDefinition(version);
389
- const executor = definition({
402
+ const executor = this.definition({
390
403
  props: {
391
404
  runtimeInstanceId: this.ctx.id.toString(),
392
405
  requestId,
@@ -428,7 +441,12 @@ export abstract class WorkflowRuntime<
428
441
  if (result.resume.type === "immediate") continue;
429
442
 
430
443
  // A 'suspended' resume hint indicates that the workflow should suspend itself and wait for the next alarm or inbound event to resume.
431
- if (result.resume.type === "suspended") break;
444
+ if (result.resume.type === "suspended") {
445
+ if (result.resume.wakeAt !== undefined) {
446
+ await this.ctx.storage.setAlarm(result.resume.wakeAt);
447
+ }
448
+ break;
449
+ }
432
450
 
433
451
  break;
434
452
  } catch (error) {
@@ -476,201 +494,130 @@ export abstract class WorkflowRuntime<
476
494
  export class WorkflowRuntimeContext extends RpcTarget {
477
495
  private readonly storage: DurableObjectStorage;
478
496
  private readonly sql: SqlStorage;
479
- private readonly requestId?: string;
480
497
  private static readonly BACKOFF_DELAYS = [250, 500, 1_000, 2_000, 4_000, 8_000, 10_000] as const;
481
498
 
482
499
  private static readonly DEFAULT_MAX_ATTEMPTS = 3;
483
500
 
484
- constructor(storage: DurableObjectStorage, options?: { requestId?: string }) {
501
+ constructor(storage: DurableObjectStorage) {
485
502
  super();
486
503
  this.storage = storage;
487
504
  this.sql = storage.sql;
488
- this.requestId = options?.requestId;
489
- }
490
-
491
- public async getOrCreateStep(
492
- id: RunStepId,
493
- options: { type: "run"; maxAttempts?: number | null; parentStepId: RunStepId | null }
494
- ): Promise<RunStep>;
495
- public async getOrCreateStep(
496
- id: SleepStepId,
497
- options: { type: "sleep"; wakeAt: Date; parentStepId: RunStepId | null }
498
- ): Promise<SleepStep>;
499
- public async getOrCreateStep(
500
- id: WaitStepId,
501
- options: { type: "wait"; eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
502
- ): Promise<WaitStep>;
503
- public async getOrCreateStep(
504
- id: RunStepId | SleepStepId | WaitStepId,
505
- options:
506
- | { type: "run"; maxAttempts?: number | null; parentStepId: RunStepId | null }
507
- | { type: "sleep"; wakeAt: Date; parentStepId: RunStepId | null }
508
- | { type: "wait"; eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
509
- ): Promise<Step> {
510
- try {
511
- if (options.type === "run") {
512
- return await this.getOrCreateRunStep(id as RunStepId, {
513
- maxAttempts: options?.maxAttempts,
514
- parentStepId: options.parentStepId
515
- });
516
- } else if (options.type === "sleep") {
517
- return await this.getOrCreateSleepStep(id as SleepStepId, {
518
- wakeAt: options.wakeAt,
519
- parentStepId: options.parentStepId
520
- });
521
- } else {
522
- return await this.getOrCreateWaitStep(id as WaitStepId, {
523
- eventName: options.eventName,
524
- timeoutAt: options.timeoutAt,
525
- parentStepId: options.parentStepId
526
- });
527
- }
528
- } catch (error) {
529
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
530
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
531
- throw new WorkflowInvariantError(error.message);
532
- }
533
-
534
- // All other errors are considered to be infrastructure/critical errors and may cause the DO to be reset
535
- // The following are examples of such errors:
536
- // 'SQLITE_FULL', // Database or disk is full
537
- // 'SQLITE_IOERR', // I/O error
538
- // 'SQLITE_BUSY', // Database is locked
539
- // 'SQLITE_NOMEM', // Out of memory
540
- // 'SQLITE_INTERRUPT', // Operation interrupted
541
- // 'SQLITE_CORRUPT', // Database file is corrupted
542
- // 'SQLITE_CANTOPEN', // Cannot open database file
543
- throw error;
544
- }
545
505
  }
546
506
 
547
507
  /**
548
- * True if this run step has at least one **direct** child that still **explains** a parent left in `running`:
549
- * typically `run` in `running` or `pending`, `sleep`/`wait` in `waiting`, or a successful-but-not-failed child row
550
- * (`succeeded` run, `satisfied` wait, `elapsed` sleep) while the parent has not yet recorded its own outcome.
508
+ * Loads the `inbound_events` row whose `claimed_by` is this wait step (`waitStepId`).
551
509
  *
552
- * Excludes only terminal **failure** child states (`failed`, `timed_out`).
510
+ * @throws If no such row exists (storage invariant broken or the step is not in a satisfied state with a claim).
553
511
  */
554
- public async hasRunningOrWaitingChildSteps(stepId: RunStepId): Promise<boolean> {
555
- const rows = this.sql
556
- .exec<{ x: number }>(
557
- `SELECT 1 AS x FROM steps
558
- WHERE parent_step_id = ?
559
- AND state NOT IN ('failed', 'timed_out')
560
- LIMIT 1`,
561
- stepId
562
- )
512
+ private getInboundEventForWaitStep<T extends Json | undefined>(stepId: WaitStepId): InboundEvent<T> {
513
+ const [row] = this.sql
514
+ .exec<ClaimedInboundEvent_Row>(`SELECT * FROM inbound_events WHERE claimed_by = ? LIMIT 1`, stepId)
563
515
  .toArray();
564
- return rows.length > 0;
516
+ if (row === undefined) {
517
+ throw new Error(
518
+ `Wait step '${stepId}' is satisfied in durable state but no inbound_events row is claimed by this step.`
519
+ );
520
+ }
521
+ return {
522
+ id: row.id,
523
+ eventName: row.event_name,
524
+ payload: (row.payload === null ? undefined : JSON.parse(row.payload)) as T,
525
+ createdAt: new Date(row.created_at),
526
+ claimedBy: row.claimed_by,
527
+ claimedAt: new Date(row.claimed_at)
528
+ };
565
529
  }
566
530
 
567
- private async getOrCreateRunStep(
531
+ getOrCreateRunStep(
568
532
  id: RunStepId,
569
- options: { maxAttempts?: number | null; parentStepId: RunStepId | null }
570
- ): Promise<RunStep> {
571
- const maxAttempts =
572
- options.maxAttempts === undefined ? WorkflowRuntimeContext.DEFAULT_MAX_ATTEMPTS : options.maxAttempts;
573
- return await this.storage.transaction(async (transaction) => {
574
- const [existing] = this.sql.exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id).toArray();
575
- // If the step does not exist, we create it and mark the attempt as 'pending'.
576
- if (existing === undefined) {
577
- const inserted = this.sql
578
- .exec<RunStep_Row>(
579
- `INSERT INTO steps (id, type, state, attempt_count, max_attempts, next_attempt_at, parent_step_id) VALUES (?, 'run', 'pending', 0, ?, CAST(unixepoch('subsecond') * 1000 AS INTEGER), ?) RETURNING *`,
580
- id,
581
- maxAttempts,
582
- options.parentStepId
583
- )
584
- .one();
585
-
586
- return formatStep(inserted);
587
- } else {
588
- // If the step exists and is in 'pending' state, we update the alarm to wake at the correct time.
589
- if (existing.state === "pending" && Date.now() < existing.next_attempt_at) {
590
- await transaction.setAlarm(existing.next_attempt_at);
591
- }
592
- return formatStep(existing);
593
- }
594
- });
595
- }
596
-
597
- private async getOrCreateSleepStep(
598
- id: SleepStepId,
599
- options: { wakeAt: Date; parentStepId: RunStepId | null }
600
- ): Promise<SleepStep> {
601
- return await this.storage.transaction(async (transaction) => {
602
- const [existing] = this.sql
603
- .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
604
- .toArray();
605
- if (existing !== undefined) {
606
- // If the step exists and is in 'waiting' state, we update the alarm to wake at the correct time.
607
- if (existing.state === "waiting" && Date.now() < existing.wake_at) {
608
- await transaction.setAlarm(existing.wake_at);
609
- }
610
- return formatStep(existing);
611
- }
612
-
613
- // If the step does not exist, we create it, set it to 'waiting' state and set the alarm to wake at the correct time.
614
- const wakeAt = options.wakeAt.getTime();
533
+ options: {
534
+ maxAttempts?: number | null;
535
+ parentStepId: RunStepId | null;
536
+ }
537
+ ): RunStep & { attempts: RunStepAttempt[] } {
538
+ const [existing] = this.sql.exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id).toArray();
539
+ if (existing === undefined) {
615
540
  const inserted = this.sql
616
- .exec<SleepStep_Row>(
617
- `INSERT INTO steps (id, type, state, wake_at, parent_step_id) VALUES (?, 'sleep', 'waiting', ?, ?) RETURNING *`,
541
+ .exec<RunStep_Row>(
542
+ `INSERT INTO steps (id, type, parent_step_id, max_attempts) VALUES (?, 'run', ?, ?) RETURNING *`,
618
543
  id,
619
- wakeAt,
620
- options.parentStepId
544
+ options.parentStepId,
545
+ options.maxAttempts ?? WorkflowRuntimeContext.DEFAULT_MAX_ATTEMPTS
621
546
  )
622
547
  .one();
623
- this.sql.exec("INSERT INTO step_events (step_id, type, wake_at) VALUES (?, ?, ?)", id, "sleep_waiting", wakeAt);
624
- await transaction.setAlarm(wakeAt);
625
- return formatStep(inserted);
626
- });
548
+ return { ...formatRunStep(inserted), attempts: [] };
549
+ } else {
550
+ const attempts = this.sql
551
+ .exec<RunStepAttempt_Row>(
552
+ `SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC, id ASC`,
553
+ id
554
+ )
555
+ .toArray();
556
+
557
+ return {
558
+ ...formatRunStep(existing),
559
+ attempts: attempts.map((attempt) => formatRunStepAttempt(attempt))
560
+ };
561
+ }
627
562
  }
628
563
 
629
- private async getOrCreateWaitStep(
564
+ getOrCreateSleepStep(id: SleepStepId, options: { wakeAt: Date; parentStepId: RunStepId | null }): SleepStep {
565
+ const [existing] = this.sql
566
+ .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
567
+ .toArray();
568
+ if (existing !== undefined) {
569
+ return formatSleepStep(existing);
570
+ }
571
+
572
+ const wakeAt = options.wakeAt.getTime();
573
+ const inserted = this.sql
574
+ .exec<SleepStep_Row>(
575
+ `INSERT INTO steps (id, type, state, target_wake_at, parent_step_id) VALUES (?, 'sleep', 'waiting', ?, ?) RETURNING *`,
576
+ id,
577
+ wakeAt,
578
+ options.parentStepId
579
+ )
580
+ .one();
581
+ return formatSleepStep(inserted);
582
+ }
583
+
584
+ getOrCreateWaitStep<T extends Json | undefined>(
630
585
  id: WaitStepId,
631
586
  options: { eventName: string; timeoutAt?: Date; parentStepId: RunStepId | null }
632
- ): Promise<WaitStep> {
633
- return await this.storage.transaction(async (transaction) => {
634
- const [existing] = this.sql
635
- .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
636
- .toArray();
637
- // If the step exists and isn't in 'waiting' state (i.e. in terminal state of 'satisfied' or 'timed_out'), we return the step as is as no further action is needed.
638
- if (existing !== undefined && existing.state !== "waiting") {
639
- return formatStep(existing);
587
+ ): WaitStep<T> {
588
+ const [existing] = this.sql.exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id).toArray();
589
+ // If the step exists and isn't in 'waiting' state (i.e. in terminal state of 'satisfied' or 'timed_out'), we return the step as is as no further action is needed.
590
+ if (existing !== undefined && existing.state !== "waiting") {
591
+ if (existing.state === "satisfied") {
592
+ return formatSatisfiedWaitStep<T>(existing, this.getInboundEventForWaitStep<T>(existing.id).payload);
593
+ } else if (existing.state === "timed_out") {
594
+ return formatTimedOutWaitStep(existing);
640
595
  }
596
+ }
641
597
 
642
- let waiting: Extract<WaitStep_Row, { state: "waiting" }>;
643
- if (existing !== undefined) {
644
- waiting = existing;
645
- } else {
646
- waiting = this.sql
647
- .exec<Extract<WaitStep_Row, { state: "waiting" }>>(
648
- `
598
+ let waiting: Extract<WaitStep_Row, { state: "waiting" }>;
599
+ if (existing !== undefined) {
600
+ waiting = existing;
601
+ } else {
602
+ waiting = this.sql
603
+ .exec<Extract<WaitStep_Row, { state: "waiting" }>>(
604
+ `
649
605
  INSERT INTO steps (id, type, state, event_name, timeout_at, parent_step_id)
650
606
  VALUES (?, 'wait', 'waiting', ?, ?, ?)
651
607
  RETURNING *
652
608
  `,
653
- id,
654
- options.eventName,
655
- options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null,
656
- options.parentStepId
657
- )
658
- .one();
659
- this.sql.exec(
660
- `INSERT INTO step_events (step_id, type, event_name, timeout_at) VALUES (?, ?, ?, ?)`,
661
609
  id,
662
- "wait_waiting",
663
610
  options.eventName,
664
- options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null
665
- );
666
- }
667
-
668
- const timeoutAt = waiting.timeout_at;
611
+ options.timeoutAt !== undefined ? options.timeoutAt.getTime() : null,
612
+ options.parentStepId
613
+ )
614
+ .one();
615
+ }
669
616
 
670
- // Attempt to claim any inbound event that is not claimed yet for the given event name.
671
- const [event] = this.sql
672
- .exec<{ id: string; payload: string }>(
673
- `
617
+ // Attempt to claim any inbound event that is not claimed yet for the given event name.
618
+ const [claimed] = this.sql
619
+ .exec<{ id: string; payload: string | null }>(
620
+ `
674
621
  UPDATE inbound_events
675
622
  SET claimed_by = ?,
676
623
  claimed_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
@@ -685,280 +632,228 @@ export class WorkflowRuntimeContext extends RpcTarget {
685
632
  AND claimed_by IS NULL
686
633
  RETURNING id, payload
687
634
  `,
688
- id,
689
- options.eventName
690
- )
691
- .toArray();
635
+ id,
636
+ options.eventName
637
+ )
638
+ .toArray();
692
639
 
693
- // If a queued inbound event was found, we mark the step as 'satisfied' and return the satisfied step.
694
- if (event !== undefined) {
695
- const satisfied = this.sql
696
- .exec<WaitStep_Row>(
697
- `
640
+ if (claimed !== undefined) {
641
+ const satisfied = this.sql
642
+ .exec<SatisfiedWaitStep_Row>(
643
+ `
698
644
  UPDATE steps
699
645
  SET state = 'satisfied',
700
- payload = ?,
701
- resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER),
702
- timeout_at = NULL
646
+ resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER)
703
647
  WHERE id = ?
704
648
  AND type = 'wait'
705
649
  AND state = 'waiting'
706
650
  RETURNING *
707
651
  `,
708
- event.payload,
709
- id
710
- )
711
- .one();
712
-
713
- this.sql.exec(
714
- `
715
- INSERT INTO step_events (step_id, type, payload)
716
- VALUES (?, ?, ?)
717
- `,
718
- id,
719
- "wait_satisfied",
720
- event.payload
721
- );
652
+ id
653
+ )
654
+ .one();
655
+ return formatSatisfiedWaitStep<T>(satisfied, claimed.payload === null ? undefined : JSON.parse(claimed.payload));
656
+ }
722
657
 
723
- return formatStep(satisfied);
724
- }
725
- // If no queued inbound event was found, we return the step as is.
726
- else {
727
- if (timeoutAt !== null && Date.now() < timeoutAt) {
728
- await transaction.setAlarm(timeoutAt);
729
- } else {
730
- await transaction.deleteAlarm();
731
- }
732
- return formatStep(waiting);
733
- }
734
- });
658
+ return formatWaitingWaitStep(waiting);
735
659
  }
736
- async handleRunAttemptEvent(
737
- id: RunStepId,
738
- event:
739
- | { type: "running"; attemptCount: number }
740
- | { type: "succeeded"; attemptCount: number; result: string }
741
- | {
742
- type: "failed";
743
- attemptCount: number;
744
- errorMessage: string;
745
- errorName?: string;
746
- isNonRetryableStepError?: boolean;
747
- }
748
- ): Promise<void> {
749
- try {
750
- await this.storage.transaction(async (transaction) => {
751
- const [existing] = this.sql
752
- .exec<RunStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'run'", id)
753
- .toArray();
754
- if (existing === undefined) {
755
- throw new WorkflowInvariantError(`Step '${id}' of type 'run' not found.`);
756
- }
757
660
 
758
- const attemptCount = event.attemptCount;
661
+ /**
662
+ * True if this run step has at least one **direct** child that is still in progress.
663
+ */
664
+ hasInProgressChildSteps(stepId: RunStepId): boolean {
665
+ const rows = this.sql
666
+ .exec<{ x: number }>(
667
+ `SELECT 1 AS x FROM steps c
668
+ WHERE c.parent_step_id = ?
669
+ AND (
670
+ (c.type = 'sleep' AND c.state IN ('waiting', 'elapsed'))
671
+ OR (c.type = 'wait' AND c.state IN ('waiting', 'satisfied'))
672
+ OR (
673
+ c.type = 'run'
674
+ AND NOT EXISTS (
675
+ SELECT 1
676
+ FROM run_step_attempts a
677
+ WHERE a.step_id = c.id
678
+ AND a.state = 'failed'
679
+ AND a.next_attempt_at IS NULL
680
+ AND a.id = (
681
+ SELECT a2.id
682
+ FROM run_step_attempts a2
683
+ WHERE a2.step_id = c.id
684
+ ORDER BY a2.started_at DESC, a2.id DESC
685
+ LIMIT 1
686
+ )
687
+ )
688
+ )
689
+ )
690
+ LIMIT 1`,
691
+ stepId
692
+ )
693
+ .toArray();
694
+ return rows.length > 0;
695
+ }
759
696
 
760
- if (event.type === "running") {
761
- if (existing.state !== "pending") {
762
- throw new WorkflowInvariantError(
763
- `Unexpected state for run step '${id}'. Expected 'pending' but got ${existing.state}.`
764
- );
765
- }
766
- if (existing.next_attempt_at !== null && existing.next_attempt_at > Date.now()) {
767
- throw new WorkflowInvariantError(
768
- `Unexpected next attempt at for run step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.next_attempt_at).toISOString()}.`
769
- );
770
- }
771
- if (existing.attempt_count !== attemptCount - 1) {
772
- throw new WorkflowInvariantError(
773
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount - 1} but got ${existing.attempt_count}.`
774
- );
775
- }
776
- // Update the step to the 'running' state and insert an `attempt_started` step_events row.
777
- this.sql.exec(
778
- `UPDATE steps SET state = 'running', attempt_count = ?, next_attempt_at = NULL WHERE id = ?`,
779
- attemptCount,
780
- id
781
- );
782
- this.sql.exec(
783
- "INSERT INTO step_events (step_id, type, attempt_number) VALUES (?, ?, ?)",
784
- id,
785
- "attempt_started",
786
- attemptCount
787
- );
788
- } else if (event.type === "succeeded") {
789
- if (existing.state !== "running") {
790
- throw new WorkflowInvariantError(
791
- `Unexpected state for run step '${id}'. Expected 'running' but got ${existing.state}.`
792
- );
793
- }
794
- if (existing.attempt_count !== attemptCount) {
795
- throw new WorkflowInvariantError(
796
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount} but got ${existing.attempt_count}.`
797
- );
798
- }
799
- // Update the step to the 'succeeded' state and insert an `attempt_succeeded` step_events row.
800
- this.sql.exec(
801
- `UPDATE steps SET state = 'succeeded', result = ?, resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
802
- event.result,
803
- id
804
- );
805
- this.sql.exec(
806
- "INSERT INTO step_events (step_id, type, attempt_number, result) VALUES (?, ?, ?, ?)",
807
- id,
808
- "attempt_succeeded",
809
- attemptCount,
810
- event.result
811
- );
812
- } else if (event.type === "failed") {
813
- if (existing.state !== "running") {
814
- throw new WorkflowInvariantError(
815
- `Unexpected state for run step '${id}'. Expected 'running' but got ${existing.state}.`
816
- );
817
- }
697
+ handleRunAttemptStarted(stepId: RunStepId): StartedRunStepAttempt {
698
+ // If a run step with the given id does not exist, we throw a 'WorkflowInvariantError' indicating that the step was not found.
699
+ const [existing] = this.sql
700
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
701
+ .toArray();
702
+ if (existing === undefined) {
703
+ throw new Error(`Run step '${stepId}' not found.`);
704
+ }
818
705
 
819
- if (existing.attempt_count !== attemptCount) {
820
- throw new WorkflowInvariantError(
821
- `Unexpected attempt count for run step '${id}'. Expected ${attemptCount} but got ${existing.attempt_count}.`
822
- );
823
- }
706
+ // Get the last attempt for the step.
707
+ const [lastAttempt] = this.sql
708
+ .exec<RunStepAttempt_Row>(
709
+ "SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at DESC, id DESC",
710
+ stepId
711
+ )
712
+ .toArray();
824
713
 
825
- // If the step has reached the maximum number of attempts, we mark the step as 'failed'
826
- if (
827
- (existing.max_attempts != null && existing.attempt_count >= existing.max_attempts) ||
828
- event.isNonRetryableStepError
829
- ) {
830
- this.sql.exec(
831
- `UPDATE steps SET state = 'failed', error_message = ?, error_name = ?, resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
832
- event.errorMessage,
833
- event.errorName ?? null,
834
- id
835
- );
836
-
837
- // Insert an `attempt_failed` step_events row
838
- this.sql.exec(
839
- "INSERT INTO step_events (step_id, type, attempt_number, error_message, error_name) VALUES (?, ?, ?, ?, ?)",
840
- id,
841
- "attempt_failed",
842
- attemptCount,
843
- event.errorMessage,
844
- event.errorName ?? null
845
- );
846
- }
847
- // Otherwise (if the step hasn't reached the maximum number of attempts), we mark the step as 'pending'
848
- // and update 'next_attempt_at' to the next backoff time and set the alarm to wake up at the same time.
849
- else {
850
- const backoff =
851
- WorkflowRuntimeContext.BACKOFF_DELAYS[attemptCount - 1] ??
852
- (WorkflowRuntimeContext.BACKOFF_DELAYS[WorkflowRuntimeContext.BACKOFF_DELAYS.length - 1] as number);
853
- const nextAttemptAt = Date.now() + backoff;
854
- this.sql.exec(`UPDATE steps SET state = 'pending', next_attempt_at = ? WHERE id = ?`, nextAttemptAt, id);
855
- this.sql.exec(
856
- "INSERT INTO step_events (step_id, type, attempt_number, error_message, error_name, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?)",
857
- id,
858
- "attempt_failed",
859
- attemptCount,
860
- event.errorMessage,
861
- event.errorName ?? null,
862
- nextAttemptAt
863
- );
864
- await transaction.setAlarm(nextAttemptAt);
865
- }
866
- }
867
- });
868
- } catch (error) {
869
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
870
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
871
- throw new WorkflowInvariantError(error.message);
872
- }
873
- throw error;
714
+ // If the last attempt has been started, we throw a 'WorkflowInvariantError' indicating that the attempt is already in progress.
715
+ if (lastAttempt !== undefined && lastAttempt.state === "started") {
716
+ throw new Error(`Attempt '${lastAttempt.id}' for run step '${stepId}' is already in progress.`);
874
717
  }
718
+
719
+ // Insert a new attempt for the step and return the new attempt
720
+ const attempt = this.sql
721
+ .exec<StartedRunStepAttempt_Row>(
722
+ `INSERT INTO run_step_attempts (step_id, state) VALUES (?, 'started') RETURNING *`,
723
+ stepId
724
+ )
725
+ .one();
726
+ return formatRunStepAttempt(attempt);
875
727
  }
876
728
 
877
- handleSleepStepEvent(id: SleepStepId, event: { type: "elapsed" }): void {
878
- try {
879
- const [existing] = this.sql
880
- .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
881
- .toArray();
882
- if (existing === undefined) {
883
- throw new WorkflowInvariantError(`Step '${id}' of type 'sleep' not found.`);
884
- }
729
+ handleRunAttemptFailed(
730
+ stepId: RunStepId,
731
+ result: {
732
+ errorMessage: string;
733
+ errorName?: string;
734
+ isNonRetryableStepError?: boolean;
735
+ }
736
+ ): FailedRunStepAttempt {
737
+ // If a run step with the given id does not exist, we throw a 'WorkflowInvariantError' indicating that the step was not found.
738
+ const [existing] = this.sql
739
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
740
+ .toArray();
741
+ if (existing === undefined) {
742
+ throw new Error(`Run step '${stepId}' not found.`);
743
+ }
885
744
 
886
- if (event.type === "elapsed") {
887
- // Update the step to the 'elapsed' state and insert a `sleep_elapsed` step_events row.
888
- if (existing.state !== "waiting") {
889
- throw new WorkflowInvariantError(
890
- `Unexpected state for sleep step '${id}'. Expected 'waiting' but got ${existing.state}.`
891
- );
892
- }
893
- this.sql.exec(
894
- `UPDATE steps SET state = 'elapsed', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), wake_at = NULL WHERE id = ?`,
895
- id
896
- );
897
- this.sql.exec("INSERT INTO step_events (step_id, type) VALUES (?, ?)", id, "sleep_elapsed");
898
- }
899
- } catch (error) {
900
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
901
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
902
- throw new WorkflowInvariantError(error.message);
903
- }
904
- throw error;
745
+ // Get the last attempt for the step.
746
+ const attempts = this.sql
747
+ .exec<RunStepAttempt_Row>("SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC", stepId)
748
+ .toArray();
749
+
750
+ const lastAttempt = attempts[attempts.length - 1];
751
+ if (lastAttempt === undefined) {
752
+ throw new Error(`No attempt in progress for run step '${stepId}'.`);
753
+ }
754
+
755
+ if (result.isNonRetryableStepError || attempts.length === existing.max_attempts) {
756
+ const updated = this.sql
757
+ .exec<Extract<RunStepAttempt_Row, { state: "failed" }>>(
758
+ `UPDATE run_step_attempts SET state = 'failed', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), error_message = ?, error_name = ?, next_attempt_at = NULL WHERE id = ? AND state = 'started' RETURNING *`,
759
+ result.errorMessage,
760
+ result.errorName ?? null,
761
+ lastAttempt.id
762
+ )
763
+ .one();
764
+ return formatRunStepAttempt(updated);
765
+ } else {
766
+ const backoff =
767
+ WorkflowRuntimeContext.BACKOFF_DELAYS[attempts.length - 1] ??
768
+ (WorkflowRuntimeContext.BACKOFF_DELAYS[WorkflowRuntimeContext.BACKOFF_DELAYS.length - 1] as number);
769
+ const nextAttemptAt = Date.now() + backoff;
770
+
771
+ const updated = this.sql
772
+ .exec<Extract<RunStepAttempt_Row, { state: "failed" }>>(
773
+ `UPDATE run_step_attempts SET state = 'failed', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), error_message = ?, error_name = ?, next_attempt_at = ? WHERE id = ? AND state = 'started' RETURNING *`,
774
+ result.errorMessage,
775
+ result.errorName ?? null,
776
+ nextAttemptAt,
777
+ lastAttempt.id
778
+ )
779
+ .one();
780
+ return formatRunStepAttempt(updated);
905
781
  }
906
782
  }
907
783
 
908
- handleWaitStepEvent(id: WaitStepId, event: { type: "timed_out" }): void {
909
- try {
910
- this.storage.transactionSync(() => {
911
- const [existing] = this.sql
912
- .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
913
- .toArray();
914
- if (existing === undefined) {
915
- throw new WorkflowInvariantError(`Step '${id}' of type 'wait' not found.`);
916
- }
917
- if (existing.state !== "waiting") {
918
- throw new WorkflowInvariantError(
919
- `Unexpected state for wait step '${id}'. Expected 'waiting' but got ${existing.state}.`
920
- );
921
- }
922
- // If the step has a timeout and the timeout has not been reached, we throw an error to explain the state mismatch.
923
- if (existing.timeout_at !== null && existing.timeout_at > Date.now()) {
924
- throw new WorkflowInvariantError(
925
- `Unexpected timeout at for wait step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.timeout_at).toISOString()}.`
926
- );
927
- }
928
- // If the step has timed out, we mark the step as 'timed_out' and insert a `wait_timed_out` step_events row.
929
- if (event.type === "timed_out") {
930
- this.sql.exec(
931
- "UPDATE steps SET state = 'timed_out', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), timeout_at = NULL WHERE id = ?",
932
- id
933
- );
934
- this.sql.exec("INSERT INTO step_events (step_id, type) VALUES (?, ?)", id, "wait_timed_out");
935
- }
936
- });
937
- } catch (error) {
938
- console.error(error instanceof Error ? error : new Error(String(error)), { requestId: this.requestId });
939
- if (error instanceof Error && isSqliteInvariantViolation(error.message)) {
940
- throw new WorkflowInvariantError(error.message);
941
- }
942
- throw error;
784
+ /**
785
+ * Marks the in-flight attempt as succeeded.
786
+ *
787
+ * @param resultJson - Raw JSON string for the result value (`null` when the callback returned `undefined`). The
788
+ * `result_type` discriminator is derived: `null` `'none'`, non-null `'json'`.
789
+ */
790
+ handleRunAttemptSucceeded(stepId: RunStepId, resultJson: string | null): SucceededRunStepAttempt {
791
+ const [existing] = this.sql
792
+ .exec<RunStep_Row>(`SELECT * FROM steps WHERE id = ? AND type = 'run'`, stepId)
793
+ .toArray();
794
+ if (existing === undefined) {
795
+ throw new Error(`Run step '${stepId}' not found.`);
796
+ }
797
+
798
+ const attempts = this.sql
799
+ .exec<RunStepAttempt_Row>("SELECT * FROM run_step_attempts WHERE step_id = ? ORDER BY started_at ASC", stepId)
800
+ .toArray();
801
+
802
+ const lastAttempt = attempts[attempts.length - 1];
803
+ if (lastAttempt === undefined || lastAttempt?.state !== "started") {
804
+ throw new Error(`No attempt in progress for run step '${stepId}'.`);
943
805
  }
806
+
807
+ const resultType = resultJson === null ? "none" : "json";
808
+ const updated = this.sql
809
+ .exec<SucceededRunStepAttempt_Row>(
810
+ `UPDATE run_step_attempts SET state = 'succeeded', ended_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER), result_type = ?, result_json = ? WHERE id = ? AND state = 'started' RETURNING *`,
811
+ resultType,
812
+ resultJson,
813
+ lastAttempt.id
814
+ )
815
+ .one();
816
+ return formatRunStepAttempt(updated);
944
817
  }
945
- }
946
818
 
947
- function isSqliteInvariantViolation(message: string): boolean {
948
- return (
949
- message.includes("SQLITE_CONSTRAINT") || // Constraint violation (FK, UNIQUE, CHECK, NOT NULL)
950
- message.includes("SQLITE_MISMATCH") || // Data type mismatch
951
- message.includes("SQLITE_ERROR") || // Generic SQL error (syntax, etc.)
952
- message.includes("SQLITE_RANGE") || // Parameter index out of range
953
- message.includes("SQLITE_AUTH") || // Authorization denied (e.g., accessing _cf_ tables)
954
- message.includes("SQLITE_TOOBIG") // String or BLOB too large
955
- );
956
- }
819
+ handleSleepStepElapsed(id: SleepStepId): void {
820
+ const [existing] = this.sql
821
+ .exec<SleepStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'sleep'", id)
822
+ .toArray();
823
+ if (existing === undefined) {
824
+ throw new Error(`Step '${id}' of type 'sleep' not found.`);
825
+ }
957
826
 
958
- class WorkflowInvariantError extends Error {
959
- constructor(message: string) {
960
- super(message);
961
- this.name = "WorkflowInvariantError";
827
+ if (existing.state !== "waiting") {
828
+ throw new Error(`Unexpected state for sleep step '${id}'. Expected 'waiting' but got ${existing.state}.`);
829
+ }
830
+ this.sql.exec(
831
+ `UPDATE steps SET state = 'elapsed', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?`,
832
+ id
833
+ );
834
+ }
835
+
836
+ handleWaitStepTimedOut(id: WaitStepId): void {
837
+ this.storage.transactionSync(() => {
838
+ const [existing] = this.sql
839
+ .exec<WaitStep_Row>("SELECT * FROM steps WHERE id = ? AND type = 'wait'", id)
840
+ .toArray();
841
+ if (existing === undefined) {
842
+ throw new Error(`Step '${id}' of type 'wait' not found.`);
843
+ }
844
+ if (existing.state !== "waiting") {
845
+ throw new Error(`Unexpected state for wait step '${id}'. Expected 'waiting' but got ${existing.state}.`);
846
+ }
847
+ if (existing.timeout_at !== null && existing.timeout_at > Date.now()) {
848
+ throw new Error(
849
+ `Unexpected timeout at for wait step '${id}'. Expected a NULL value or a value that is in the past but got ${new Date(existing.timeout_at).toISOString()}.`
850
+ );
851
+ }
852
+ this.sql.exec(
853
+ "UPDATE steps SET state = 'timed_out', resolved_at = CAST(unixepoch('subsecond') * 1000 AS INTEGER) WHERE id = ?",
854
+ id
855
+ );
856
+ });
962
857
  }
963
858
  }
964
859
 
@@ -966,33 +861,27 @@ export type RunStepId = Brand<string, "RunStepId">;
966
861
  export type SleepStepId = Brand<string, "SleepStepId">;
967
862
  export type WaitStepId = Brand<string, "WaitStepId">;
968
863
 
969
- type RunStep = {
864
+ export type RunStepAttempt = {
865
+ id: string;
866
+ stepId: RunStepId;
867
+ startedAt: Date;
868
+ } & (
869
+ | { state: "started" }
870
+ | ({ state: "succeeded"; endedAt: Date } & ({ resultType: "json"; resultJson: string } | { resultType: "none" }))
871
+ | { state: "failed"; errorMessage: string; errorName?: string; endedAt: Date; nextAttemptAt?: Date }
872
+ );
873
+
874
+ export type StartedRunStepAttempt = Extract<RunStepAttempt, { state: "started" }>;
875
+ export type SucceededRunStepAttempt = Extract<RunStepAttempt, { state: "succeeded" }>;
876
+ export type FailedRunStepAttempt = Extract<RunStepAttempt, { state: "failed" }>;
877
+
878
+ export type RunStep = {
970
879
  type: "run";
971
880
  id: RunStepId;
972
881
  createdAt: Date;
973
- attemptCount: number;
974
882
  maxAttempts: number | null;
975
883
  parentStepId: RunStepId | null;
976
- } & (
977
- | {
978
- state: "pending";
979
- nextAttemptAt: Date;
980
- }
981
- | {
982
- state: "running";
983
- }
984
- | {
985
- state: "succeeded";
986
- result: string;
987
- resolvedAt: Date;
988
- }
989
- | {
990
- state: "failed";
991
- errorMessage: string;
992
- errorName?: string;
993
- resolvedAt: Date;
994
- }
995
- );
884
+ };
996
885
 
997
886
  type SleepStep = {
998
887
  type: "sleep";
@@ -1010,7 +899,7 @@ type SleepStep = {
1010
899
  }
1011
900
  );
1012
901
 
1013
- type WaitStep = {
902
+ type WaitStep<T extends Json | undefined = Json | undefined> = {
1014
903
  type: "wait";
1015
904
  id: WaitStepId;
1016
905
  createdAt: Date;
@@ -1023,256 +912,171 @@ type WaitStep = {
1023
912
  }
1024
913
  | {
1025
914
  state: "satisfied";
1026
- payload: string;
915
+ payload: T;
1027
916
  resolvedAt: Date;
917
+ timeoutAt?: Date;
1028
918
  }
1029
919
  | {
1030
920
  state: "timed_out";
1031
921
  resolvedAt: Date;
922
+ timeoutAt: Date;
1032
923
  }
1033
924
  );
1034
925
 
1035
- type Step = RunStep | SleepStep | WaitStep;
1036
-
1037
- function formatStep(step: RunStep_Row): RunStep;
1038
- function formatStep(step: SleepStep_Row): SleepStep;
1039
- function formatStep(step: WaitStep_Row): WaitStep;
1040
- function formatStep(step: Step_Row): Step;
1041
- function formatStep(step: Step_Row): Step {
1042
- switch (step.type) {
1043
- case "run": {
1044
- switch (step.state) {
1045
- case "pending":
1046
- return {
1047
- type: "run",
1048
- id: step.id,
1049
- state: "pending",
1050
- nextAttemptAt: new Date(step.next_attempt_at),
1051
- createdAt: new Date(step.created_at),
1052
- attemptCount: step.attempt_count,
1053
- maxAttempts: step.max_attempts,
1054
- parentStepId: step.parent_step_id
1055
- } satisfies RunStep;
1056
- case "running":
1057
- return {
1058
- type: "run",
1059
- id: step.id,
1060
- state: "running",
1061
- createdAt: new Date(step.created_at),
1062
- attemptCount: step.attempt_count,
1063
- maxAttempts: step.max_attempts,
1064
- parentStepId: step.parent_step_id
1065
- } satisfies RunStep;
1066
- case "succeeded":
1067
- return {
1068
- type: "run",
1069
- id: step.id,
1070
- state: "succeeded",
1071
- result: step.result,
1072
- resolvedAt: new Date(step.resolved_at),
1073
- createdAt: new Date(step.created_at),
1074
- attemptCount: step.attempt_count,
1075
- maxAttempts: step.max_attempts,
1076
- parentStepId: step.parent_step_id
1077
- } satisfies RunStep;
1078
- case "failed":
1079
- return {
1080
- type: "run",
1081
- id: step.id,
1082
- state: "failed",
1083
- errorMessage: step.error_message,
1084
- errorName: step.error_name ?? undefined,
1085
- resolvedAt: new Date(step.resolved_at),
1086
- createdAt: new Date(step.created_at),
1087
- attemptCount: step.attempt_count,
1088
- maxAttempts: step.max_attempts,
1089
- parentStepId: step.parent_step_id
1090
- } satisfies RunStep;
1091
- default:
1092
- throw new Error("Unexpected step state");
1093
- }
1094
- }
1095
- case "sleep":
1096
- switch (step.state) {
1097
- case "waiting":
1098
- return {
1099
- type: "sleep",
1100
- id: step.id,
1101
- state: "waiting",
1102
- wakeAt: new Date(step.wake_at),
1103
- createdAt: new Date(step.created_at),
1104
- parentStepId: step.parent_step_id
1105
- } satisfies SleepStep;
1106
- case "elapsed":
1107
- return {
1108
- type: "sleep",
1109
- id: step.id,
1110
- state: "elapsed",
1111
- resolvedAt: new Date(step.resolved_at),
1112
- createdAt: new Date(step.created_at),
1113
- parentStepId: step.parent_step_id
1114
- } satisfies SleepStep;
1115
- default:
1116
- throw new Error("Unexpected step state");
1117
- }
1118
- case "wait":
1119
- switch (step.state) {
1120
- case "waiting":
1121
- return {
1122
- type: "wait",
1123
- id: step.id,
1124
- state: "waiting",
1125
- eventName: step.event_name,
1126
- timeoutAt: step.timeout_at ? new Date(step.timeout_at) : undefined,
1127
- createdAt: new Date(step.created_at),
1128
- parentStepId: step.parent_step_id
1129
- } satisfies WaitStep;
1130
- case "satisfied":
1131
- return {
1132
- type: "wait",
1133
- id: step.id,
1134
- state: "satisfied",
1135
- payload: step.payload,
1136
- createdAt: new Date(step.created_at),
1137
- eventName: step.event_name,
1138
- resolvedAt: new Date(step.resolved_at),
1139
- parentStepId: step.parent_step_id
1140
- } satisfies WaitStep;
1141
- case "timed_out":
1142
- return {
1143
- type: "wait",
1144
- id: step.id,
1145
- state: "timed_out",
1146
- eventName: step.event_name,
1147
- resolvedAt: new Date(step.resolved_at),
1148
- createdAt: new Date(step.created_at),
1149
- parentStepId: step.parent_step_id
1150
- } satisfies WaitStep;
1151
- default:
1152
- throw new Error("Unexpected step state");
1153
- }
1154
- default:
1155
- throw new Error("Unexpected step type");
1156
- }
1157
- }
926
+ type WaitingWaitStep = Extract<WaitStep, { state: "waiting" }>;
927
+ type SatisfiedWaitStep<T extends Json | undefined> = Extract<WaitStep<T>, { state: "satisfied" }>;
928
+ type TimedOutWaitStep = Extract<WaitStep, { state: "timed_out" }>;
1158
929
 
1159
930
  /**
1160
- * Formatted `step_events` row for application use.
931
+ * SQLite row shape for `run_step_attempts`.
932
+ *
933
+ * Succeeded attempts use a `result_type` discriminator:
934
+ *
935
+ * - `'json'` → `result_json` holds the raw JSON value (never NULL)
936
+ * - `'none'` → callback returned `undefined`/`void`; no result data
1161
937
  */
1162
- type StepEvent = {
938
+ type RunStepAttempt_Row = {
1163
939
  id: string;
1164
- stepId: string;
1165
- recordedAt: Date;
940
+ step_id: RunStepId;
941
+ started_at: number;
1166
942
  } & (
1167
943
  | {
1168
- type: "attempt_started";
1169
- attemptNumber: number;
1170
- }
1171
- | {
1172
- type: "attempt_succeeded";
1173
- attemptNumber: number;
1174
- result?: string;
1175
- }
1176
- | {
1177
- type: "attempt_failed";
1178
- attemptNumber: number;
1179
- errorMessage: string;
1180
- errorName?: string;
1181
- nextAttemptAt?: Date;
1182
- }
1183
- | {
1184
- type: "sleep_waiting";
1185
- wakeAt: Date;
1186
- }
1187
- | {
1188
- type: "sleep_elapsed";
1189
- }
1190
- | {
1191
- type: "wait_waiting";
1192
- eventName: string;
1193
- timeoutAt?: Date;
1194
- }
1195
- | {
1196
- type: "wait_satisfied";
1197
- payload?: string;
944
+ state: "started";
1198
945
  }
946
+ | ({
947
+ state: "succeeded";
948
+ ended_at: number;
949
+ } & ({ result_type: "json"; result_json: string } | { result_type: "none" }))
1199
950
  | {
1200
- type: "wait_timed_out";
951
+ state: "failed";
952
+ error_message: string;
953
+ error_name: string | null;
954
+ ended_at: number;
955
+ next_attempt_at: number | null;
1201
956
  }
1202
957
  );
1203
958
 
1204
- function formatStepEvent(row: StepEventRow): StepEvent {
1205
- switch (row.type) {
1206
- case "attempt_started":
1207
- return {
1208
- id: row.id,
1209
- type: "attempt_started",
1210
- attemptNumber: row.attempt_number,
1211
- stepId: row.step_id,
1212
- recordedAt: new Date(row.recorded_at)
1213
- };
1214
- case "attempt_succeeded":
1215
- return {
1216
- id: row.id,
1217
- type: "attempt_succeeded",
1218
- attemptNumber: row.attempt_number,
1219
- result: row.result,
1220
- stepId: row.step_id,
1221
- recordedAt: new Date(row.recorded_at)
1222
- };
1223
- case "attempt_failed":
1224
- return {
1225
- id: row.id,
1226
- type: "attempt_failed",
1227
- attemptNumber: row.attempt_number,
1228
- errorMessage: row.error_message,
1229
- errorName: row.error_name ?? undefined,
1230
- nextAttemptAt: row.next_attempt_at ? new Date(row.next_attempt_at) : undefined,
1231
- stepId: row.step_id,
1232
- recordedAt: new Date(row.recorded_at)
1233
- };
1234
- case "sleep_waiting":
959
+ type StartedRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "started" }>;
960
+ type SucceededRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "succeeded" }>;
961
+ type FailedRunStepAttempt_Row = Extract<RunStepAttempt_Row, { state: "failed" }>;
962
+
963
+ function formatRunStepAttempt(attempt: StartedRunStepAttempt_Row): StartedRunStepAttempt;
964
+ function formatRunStepAttempt(attempt: SucceededRunStepAttempt_Row): SucceededRunStepAttempt;
965
+ function formatRunStepAttempt(attempt: FailedRunStepAttempt_Row): FailedRunStepAttempt;
966
+ function formatRunStepAttempt(attempt: RunStepAttempt_Row): RunStepAttempt;
967
+ function formatRunStepAttempt(attempt: RunStepAttempt_Row): RunStepAttempt {
968
+ switch (attempt.state) {
969
+ case "started":
1235
970
  return {
1236
- id: row.id,
1237
- type: "sleep_waiting",
1238
- wakeAt: new Date(row.wake_at),
1239
- stepId: row.step_id,
1240
- recordedAt: new Date(row.recorded_at)
971
+ id: attempt.id,
972
+ stepId: attempt.step_id,
973
+ startedAt: new Date(attempt.started_at),
974
+ state: "started"
1241
975
  };
1242
- case "sleep_elapsed":
1243
- return {
1244
- id: row.id,
1245
- type: "sleep_elapsed",
1246
- stepId: row.step_id,
1247
- recordedAt: new Date(row.recorded_at)
976
+ case "succeeded": {
977
+ const base = {
978
+ id: attempt.id,
979
+ stepId: attempt.step_id,
980
+ startedAt: new Date(attempt.started_at),
981
+ state: "succeeded" as const,
982
+ endedAt: new Date(attempt.ended_at)
1248
983
  };
1249
- case "wait_waiting":
984
+ if (attempt.result_type === "json") {
985
+ return { ...base, resultType: "json" as const, resultJson: attempt.result_json };
986
+ }
987
+ return { ...base, resultType: attempt.result_type };
988
+ }
989
+ case "failed":
1250
990
  return {
1251
- id: row.id,
1252
- type: "wait_waiting",
1253
- eventName: row.event_name,
1254
- timeoutAt: row.timeout_at ? new Date(row.timeout_at) : undefined,
1255
- stepId: row.step_id,
1256
- recordedAt: new Date(row.recorded_at)
991
+ id: attempt.id,
992
+ stepId: attempt.step_id,
993
+ startedAt: new Date(attempt.started_at),
994
+ state: "failed",
995
+ errorMessage: attempt.error_message,
996
+ errorName: attempt.error_name ?? undefined,
997
+ endedAt: new Date(attempt.ended_at),
998
+ nextAttemptAt: attempt.next_attempt_at != null ? new Date(attempt.next_attempt_at) : undefined
1257
999
  };
1258
- case "wait_satisfied":
1000
+ }
1001
+ }
1002
+
1003
+ function formatRunStep(step: RunStep_Row): RunStep {
1004
+ return {
1005
+ type: "run",
1006
+ id: step.id,
1007
+ createdAt: new Date(step.created_at),
1008
+ maxAttempts: step.max_attempts,
1009
+ parentStepId: step.parent_step_id
1010
+ };
1011
+ }
1012
+
1013
+ function formatSleepStep(step: SleepStep_Row): SleepStep {
1014
+ switch (step.state) {
1015
+ case "waiting":
1259
1016
  return {
1260
- id: row.id,
1261
- type: "wait_satisfied",
1262
- payload: row.payload,
1263
- stepId: row.step_id,
1264
- recordedAt: new Date(row.recorded_at)
1265
- };
1266
- case "wait_timed_out":
1017
+ type: "sleep",
1018
+ id: step.id,
1019
+ state: "waiting",
1020
+ wakeAt: new Date(step.target_wake_at),
1021
+ createdAt: new Date(step.created_at),
1022
+ parentStepId: step.parent_step_id
1023
+ } satisfies SleepStep;
1024
+ case "elapsed":
1267
1025
  return {
1268
- id: row.id,
1269
- type: "wait_timed_out",
1270
- stepId: row.step_id,
1271
- recordedAt: new Date(row.recorded_at)
1272
- };
1026
+ type: "sleep",
1027
+ id: step.id,
1028
+ state: "elapsed",
1029
+ resolvedAt: new Date(step.resolved_at),
1030
+ createdAt: new Date(step.created_at),
1031
+ parentStepId: step.parent_step_id
1032
+ } satisfies SleepStep;
1033
+ default:
1034
+ throw new Error("Unexpected sleep step state");
1273
1035
  }
1274
1036
  }
1275
1037
 
1038
+ function formatWaitingWaitStep(step: WaitingWaitStep_Row): WaitingWaitStep {
1039
+ return {
1040
+ type: "wait",
1041
+ id: step.id,
1042
+ state: "waiting",
1043
+ eventName: step.event_name,
1044
+ timeoutAt: step.timeout_at != null ? new Date(step.timeout_at) : undefined,
1045
+ createdAt: new Date(step.created_at),
1046
+ parentStepId: step.parent_step_id
1047
+ };
1048
+ }
1049
+
1050
+ function formatTimedOutWaitStep(step: TimedOutWaitStep_Row): TimedOutWaitStep {
1051
+ return {
1052
+ type: "wait",
1053
+ id: step.id,
1054
+ state: "timed_out",
1055
+ eventName: step.event_name,
1056
+ resolvedAt: new Date(step.resolved_at),
1057
+ createdAt: new Date(step.created_at),
1058
+ parentStepId: step.parent_step_id,
1059
+ timeoutAt: new Date(step.timeout_at)
1060
+ };
1061
+ }
1062
+
1063
+ function formatSatisfiedWaitStep<T extends Json | undefined>(
1064
+ step: SatisfiedWaitStep_Row,
1065
+ payload: T
1066
+ ): SatisfiedWaitStep<T> {
1067
+ return {
1068
+ type: "wait",
1069
+ id: step.id,
1070
+ state: "satisfied",
1071
+ payload: payload,
1072
+ createdAt: new Date(step.created_at),
1073
+ eventName: step.event_name,
1074
+ resolvedAt: new Date(step.resolved_at),
1075
+ parentStepId: step.parent_step_id,
1076
+ timeoutAt: step.timeout_at != null ? new Date(step.timeout_at) : undefined
1077
+ };
1078
+ }
1079
+
1276
1080
  /**
1277
1081
  * SQLite row shape for `workflow_events` (append-only workflow status transitions).
1278
1082
  */
@@ -1332,128 +1136,56 @@ type RunStep_Row = {
1332
1136
  * Enclosing run step id when this run step was created inside that run's callback; otherwise null.
1333
1137
  */
1334
1138
  parent_step_id: RunStepId | null;
1335
-
1336
- /**
1337
- * Number of attempts that have been durably started for this step.
1338
- *
1339
- * Invariants:
1340
- *
1341
- * - `0` before the first attempt starts
1342
- * - Incremented exactly when an attempt transitions into `running`
1343
- * - Never decremented
1344
- * - Unchanged while the step is pending between retries/backoff
1345
- *
1346
- * When greater than `0`, this is also the 1-based number of the most recently started attempt. The next attempt, if
1347
- * one is started, will have number `attempts_started + 1`.
1348
- */
1349
- attempt_count: number;
1350
1139
  /**
1351
1140
  * Maximum number of attempts that can be made for this step. If not present, the step can be retried indefinitely. If
1352
1141
  * present, the step can be retried up to this number of times. If the step has reached the maximum number of
1353
1142
  * attempts, it will transition to the `failed` state.
1354
1143
  */
1355
1144
  max_attempts: number | null;
1356
- } & (
1357
- | {
1358
- /**
1359
- * The step has been reached but is not yet resolved, and no attempt is currently in progress.
1360
- *
1361
- * This includes steps that: 1. have been reached but have not yet started their first attempt 2. are waiting
1362
- * until a retry/backoff time 3. are eligible to run immediately.
1363
- */
1364
- state: "pending";
1365
-
1366
- /**
1367
- * Earliest time at which the next attempt may start.
1368
- *
1369
- * Semantics: - On first creation, this is typically set to "now", meaning the step is immediately runnable. -
1370
- * After a failed attempt with backoff, this is set to the retry time. - While the current time is before this
1371
- * value, the step remains `pending` and no new attempt may start. - Once the current time reaches or passes this
1372
- * value, the step becomes eligible to transition from `pending` to `running`.
1373
- */
1374
- next_attempt_at: number;
1375
- }
1376
- | {
1377
- /**
1378
- * An attempt was durably started, but its outcome has not yet been durably recorded.
1379
- *
1380
- * This does not guarantee that code is actively executing at this instant. After a restart or interruption,
1381
- * `running` means only that a start was recorded and no durable success/failure was recorded afterward.
1382
- */
1383
- state: "running";
1384
- }
1385
- | {
1386
- /**
1387
- * The step completed successfully and its result was durably recorded.
1388
- */
1389
- state: "succeeded";
1390
-
1391
- /**
1392
- * Serialized successful result for the step.
1393
- */
1394
- result: string;
1395
-
1396
- /**
1397
- * Time at which the step became terminal by succeeding.
1398
- */
1399
- resolved_at: number;
1400
- }
1401
- | {
1402
- /**
1403
- * The step failed terminally and no further attempts will be made.
1404
- */
1405
- state: "failed";
1406
-
1407
- /**
1408
- * Serialized failure message for the terminal failure.
1409
- */
1410
- error_message: string;
1411
-
1412
- /**
1413
- * Optional serialized error class/name for the terminal failure.
1414
- */
1415
- error_name: string | null;
1416
-
1417
- /**
1418
- * Time at which the step became terminal by failing.
1419
- */
1420
- resolved_at: number;
1421
- }
1422
- );
1145
+ };
1423
1146
 
1424
1147
  type SleepStep_Row = {
1425
1148
  id: SleepStepId;
1426
1149
  type: "sleep";
1427
1150
  created_at: number;
1428
1151
  /**
1429
- * Enclosing run step id when this sleep step was created inside that run's callback; otherwise null.
1152
+ * Id of the enclosing run step if this sleep step was created from within that run step's callback; otherwise null.
1430
1153
  */
1431
1154
  parent_step_id: RunStepId | null;
1432
1155
  } & (
1433
1156
  | {
1434
1157
  /**
1435
- * The sleep step has been reached and is currently in effect.
1158
+ * The sleep step has started and is not yet terminal.
1436
1159
  *
1437
- * `waiting` here means "started but not yet resolved", not "not yet started". The step remains waiting until its
1438
- * wake time is reached.
1160
+ * In this state, the step is considered active until its target wake time is reached and that transition is
1161
+ * durably recorded.
1439
1162
  */
1440
1163
  state: "waiting";
1441
1164
 
1442
1165
  /**
1443
- * Earliest time at which the sleep condition becomes satisfied.
1166
+ * Target time at or after which this sleep step may transition to `elapsed`.
1444
1167
  *
1445
- * Before this moment, the step remains waiting. At or after this moment, the step may be marked elapsed.
1168
+ * This is part of the step's configured behavior, not an indicator that the step is still pending.
1446
1169
  */
1447
- wake_at: number;
1170
+ target_wake_at: number;
1448
1171
  }
1449
1172
  | {
1450
1173
  /**
1451
- * The sleep interval elapsed and that fact was durably recorded.
1174
+ * The sleep step has completed because its target wake time was reached and that outcome was durably recorded.
1452
1175
  */
1453
1176
  state: "elapsed";
1454
1177
 
1455
1178
  /**
1456
- * Time at which the sleep step became terminal by completing.
1179
+ * Target time at or after which this sleep step became eligible to transition to `elapsed`.
1180
+ *
1181
+ * Retained on terminal rows so the completed step still carries its original timing configuration.
1182
+ */
1183
+ target_wake_at: number;
1184
+
1185
+ /**
1186
+ * Time at which the transition to `elapsed` was durably recorded.
1187
+ *
1188
+ * This may be equal to or later than `target_wake_at`.
1457
1189
  */
1458
1190
  resolved_at: number;
1459
1191
  }
@@ -1464,242 +1196,146 @@ type WaitStep_Row = {
1464
1196
  type: "wait";
1465
1197
  created_at: number;
1466
1198
  /**
1467
- * Enclosing run step id when this wait step was created inside that run's callback; otherwise null.
1199
+ * Id of the enclosing run step if this wait step was created from within that run step's callback; otherwise null.
1468
1200
  */
1469
1201
  parent_step_id: RunStepId | null;
1470
1202
 
1471
1203
  /**
1472
- * Name of the inbound event that can satisfy this step.
1204
+ * Name of the inbound event that can satisfy this wait step.
1473
1205
  */
1474
1206
  event_name: string;
1475
1207
  } & (
1476
1208
  | {
1477
1209
  /**
1478
- * The step has been reached, but the expected event has not yet been durably received, and no timeout failure has
1479
- * been durably recorded.
1210
+ * The wait step has started and is not yet terminal.
1211
+ *
1212
+ * In this state, the expected event has not yet been durably matched to the step, and no timeout outcome has been
1213
+ * durably recorded.
1480
1214
  */
1481
1215
  state: "waiting";
1482
1216
 
1483
1217
  /**
1484
- * Optional deadline after which the wait step may fail.
1218
+ * Optional time at or after which this wait step may transition to `timed_out`.
1485
1219
  *
1486
- * - If omitted, the step may wait indefinitely.
1487
- * - If present, reaching or passing this time allows the step to transition to `failed`.
1220
+ * - If null, the step may wait indefinitely.
1221
+ * - If set, reaching or passing this time makes the step eligible to time out.
1488
1222
  */
1489
1223
  timeout_at: number | null;
1490
1224
  }
1491
1225
  | {
1492
1226
  /**
1493
- * The expected event was received and its payload was durably recorded.
1227
+ * The wait step has completed because a matching event was durably received and recorded.
1494
1228
  */
1495
1229
  state: "satisfied";
1496
1230
 
1497
1231
  /**
1498
- * Serialized payload of the event that satisfied the wait.
1232
+ * Optional timeout that applied while this step was active.
1233
+ *
1234
+ * Retained on terminal rows so the completed step still carries its original timing configuration.
1499
1235
  */
1500
- payload: string;
1236
+ timeout_at: number | null;
1501
1237
 
1502
1238
  /**
1503
- * Time at which the wait step became terminal by receiving the expected event.
1239
+ * Time at which the transition to `satisfied` was durably recorded.
1504
1240
  */
1505
1241
  resolved_at: number;
1506
1242
  }
1507
1243
  | {
1508
1244
  /**
1509
- * The step failed because its timeout was reached before the expected event was durably recorded.
1245
+ * The wait step has completed by timing out before a matching event was durably received.
1510
1246
  */
1511
1247
  state: "timed_out";
1512
1248
 
1513
1249
  /**
1514
- * Time at which the wait step became terminal by timing out.
1250
+ * Time at or after which this step became eligible to transition to `timed_out`.
1251
+ */
1252
+ timeout_at: number;
1253
+
1254
+ /**
1255
+ * Time at which the transition to `timed_out` was durably recorded.
1256
+ *
1257
+ * This may be equal to or later than `timeout_at`.
1515
1258
  */
1516
1259
  resolved_at: number;
1517
1260
  }
1518
1261
  );
1519
1262
 
1263
+ type WaitingWaitStep_Row = Extract<WaitStep_Row, { state: "waiting" }>;
1264
+ type SatisfiedWaitStep_Row = Extract<WaitStep_Row, { state: "satisfied" }>;
1265
+ type TimedOutWaitStep_Row = Extract<WaitStep_Row, { state: "timed_out" }>;
1266
+
1520
1267
  /**
1521
1268
  * A step row is created once the workflow reaches that step. From that point on, `status` describes the step's durable
1522
1269
  * lifecycle state.
1523
1270
  */
1524
1271
  type Step_Row = RunStep_Row | SleepStep_Row | WaitStep_Row;
1525
1272
 
1526
- /**
1527
- * SQLite row shape for `step_events`: append-only durable transitions for steps.
1528
- *
1529
- * The `steps` table holds current state; `step_events` records how that state evolved.
1530
- */
1531
- type StepEventRow = {
1532
- id: string;
1533
-
1534
- /**
1535
- * `steps.id` this event applies to.
1536
- */
1537
- step_id: RunStepId | SleepStepId | WaitStepId;
1538
-
1539
- /**
1540
- * When this event row was persisted (`unixepoch` ms).
1541
- */
1542
- recorded_at: number;
1543
- } & (
1544
- | {
1545
- /**
1546
- * A run step attempt was durably started.
1547
- *
1548
- * This corresponds to the step transitioning from `pending` → `running`.
1549
- */
1550
- type: "attempt_started";
1551
-
1552
- /**
1553
- * 1-based attempt number.
1554
- *
1555
- * Equals the value of `attempts_started` after the transition.
1556
- */
1557
- attempt_number: number;
1558
- }
1559
- | {
1560
- /**
1561
- * A run step attempt completed successfully.
1562
- *
1563
- * The step transitioned from `running` to `succeeded`.
1564
- */
1565
- type: "attempt_succeeded";
1566
-
1567
- attempt_number: number;
1568
-
1569
- /**
1570
- * Serialized result produced by the step.
1571
- */
1572
- result: string;
1573
- }
1574
- | {
1575
- /**
1576
- * A run step attempt failed.
1577
- *
1578
- * The step either: - scheduled a next attempt, or - transitioned to terminal failure.
1579
- */
1580
- type: "attempt_failed";
1581
-
1582
- attempt_number: number;
1583
-
1584
- error_message: string;
1585
- error_name: string | null;
1586
-
1587
- /**
1588
- * If present, the next attempt time.
1589
- *
1590
- * Absence indicates this failure was terminal.
1591
- */
1592
- next_attempt_at?: number;
1593
- }
1594
- | {
1595
- /**
1596
- * A sleep step began waiting.
1597
- *
1598
- * Corresponds to the step entering `waiting`.
1599
- */
1600
- type: "sleep_waiting";
1601
-
1602
- /**
1603
- * Wake time for the sleep step.
1604
- */
1605
- wake_at: number;
1606
- }
1607
- | {
1608
- /**
1609
- * A sleep step completed because the wake condition became satisfied.
1610
- */
1611
- type: "sleep_elapsed";
1612
- }
1613
- | {
1614
- /**
1615
- * A wait step began waiting for the expected event.
1616
- */
1617
- type: "wait_waiting";
1618
-
1619
- event_name: string;
1620
- timeout_at: number | null;
1621
- }
1622
- | {
1623
- /**
1624
- * A wait step was satisfied by receiving the expected event.
1625
- */
1626
- type: "wait_satisfied";
1627
-
1628
- payload: string;
1629
- }
1630
- | {
1631
- /**
1632
- * A wait step failed because its timeout deadline was reached.
1633
- */
1634
- type: "wait_timed_out";
1635
- }
1636
- );
1637
-
1638
1273
  export type WorkflowStatus =
1639
- | "pending" // The workflow has been created but 'run' hasn't been called yet
1274
+ | "pending" // Durable metadata exists, but create() has not initialized the workflow input yet
1275
+ | "initialized" // create() has pinned the workflow input, but execution has not started yet
1640
1276
  | "running" // The workflow is currently executing; steps are being created/processed
1641
1277
  | "paused" // The workflow is paused and will not make progress until resumed
1642
1278
  | "completed" // The workflow completed successfully; ('Workflow.next' returned { done: true, status: "succeeded" })
1643
1279
  | "failed" // A step exhausted retries and the workflow aborted; ('Workflow.next' returned { done: true, status: "failed" })
1644
1280
  | "cancelled"; // The workflow was terminated explicitly by the user.
1645
1281
 
1646
- type WorkflowMetadata_Row<TVersion extends string> = {
1282
+ type WorkflowMetadata_Row = {
1647
1283
  created_at: number;
1648
1284
  updated_at: number;
1649
1285
  status: WorkflowStatus;
1650
- definition_version: TVersion | null;
1651
1286
  definition_input: string | null;
1652
1287
  };
1653
1288
 
1654
- type WorkflowMetadata<TVersion extends string> = {
1655
- createdAt: Date;
1656
- updatedAt: Date;
1657
- status: WorkflowStatus;
1658
- definitionVersion?: TVersion;
1659
- definitionInput?: Json;
1660
- };
1661
-
1662
- export function formatWorkflowMetadata<TVersion extends string>(
1663
- metadata: WorkflowMetadata_Row<TVersion>
1664
- ): WorkflowMetadata<TVersion> {
1665
- return {
1666
- createdAt: new Date(metadata.created_at),
1667
- updatedAt: new Date(metadata.updated_at),
1668
- status: metadata.status,
1669
- definitionVersion: metadata.definition_version ?? undefined,
1670
- definitionInput: metadata.definition_input ? JSON.parse(metadata.definition_input) : undefined
1671
- };
1672
- }
1673
1289
  /**
1674
1290
  * Durable record of inbound events (`inbound_events`) that may satisfy wait steps.
1675
1291
  *
1676
1292
  * Persists delivered signals across restarts so step resolution is based on durable state rather than in-memory state.
1677
1293
  */
1678
- export type InboundEventRow = {
1679
- /**
1680
- * Unique identifier for the event.
1681
- */
1294
+ type InboundEvent_Row = {
1682
1295
  id: string;
1683
- /**
1684
- * Name of the event.
1685
- *
1686
- * Used by wait steps to determine whether this event can satisfy them.
1687
- */
1688
1296
  event_name: string;
1689
1297
  /**
1690
- * Serialized payload delivered with the event.
1691
- */
1692
- payload: string;
1693
- /**
1694
- * Time the event was durably recorded.
1298
+ * Raw JSON value, or SQL NULL for undefined (no payload).
1695
1299
  */
1300
+ payload: string | null;
1696
1301
  created_at: number;
1697
- /**
1698
- * Step that claimed the event.
1699
- */
1700
- claimed_by?: RunStepId | SleepStepId | WaitStepId;
1701
- /**
1702
- * Time the event was claimed.
1703
- */
1704
- claimed_at?: number;
1705
- };
1302
+ } & (
1303
+ | {
1304
+ /**
1305
+ * Step that claimed the event.
1306
+ */
1307
+ claimed_by: WaitStepId;
1308
+ /**
1309
+ * Time the event was claimed.
1310
+ */
1311
+ claimed_at: number;
1312
+ }
1313
+ | {
1314
+ claimed_by: null;
1315
+ claimed_at: null;
1316
+ }
1317
+ );
1318
+
1319
+ type ClaimedInboundEvent_Row = Extract<
1320
+ InboundEvent_Row,
1321
+ {
1322
+ claimed_by: WaitStepId;
1323
+ claimed_at: number;
1324
+ }
1325
+ >;
1326
+
1327
+ type InboundEvent<T extends Json | undefined = Json | undefined> = {
1328
+ id: string;
1329
+ eventName: string;
1330
+ payload: T;
1331
+ createdAt: Date;
1332
+ } & (
1333
+ | {
1334
+ claimedBy: WaitStepId;
1335
+ claimedAt: Date;
1336
+ }
1337
+ | {
1338
+ claimedBy?: never;
1339
+ claimedAt?: never;
1340
+ }
1341
+ );