@strav/durable 0.4.27
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/CHANGELOG.md +26 -0
- package/package.json +49 -0
- package/src/builder.ts +158 -0
- package/src/config.ts +36 -0
- package/src/durable.ts +268 -0
- package/src/engine/advance_handler.ts +154 -0
- package/src/engine/compensate_handler.ts +70 -0
- package/src/engine/compensation_driver.ts +61 -0
- package/src/engine/context.ts +36 -0
- package/src/engine/enqueue.ts +62 -0
- package/src/engine/finalize.ts +111 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/run_store.ts +42 -0
- package/src/engine/step_driver.ts +291 -0
- package/src/engine/suspended_run.ts +24 -0
- package/src/errors.ts +21 -0
- package/src/helpers.ts +16 -0
- package/src/index.ts +37 -0
- package/src/models/index.ts +3 -0
- package/src/models/journal.ts +54 -0
- package/src/models/run_machine.ts +39 -0
- package/src/models/workflow_run.ts +36 -0
- package/src/providers/durable_provider.ts +31 -0
- package/src/providers/index.ts +2 -0
- package/src/registry.ts +35 -0
- package/src/schema.ts +70 -0
- package/src/types.ts +216 -0
- package/src/util.ts +25 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain composition point — duck-typed, with no `@strav/brain` dependency.
|
|
3
|
+
*
|
|
4
|
+
* `@strav/brain`'s `AgentRunner.run()` returns either an `AgentResult` or a
|
|
5
|
+
* `SuspendedRun` ({ status: 'suspended', pendingToolCalls, state }). When a
|
|
6
|
+
* durable `.step` handler returns a `SuspendedRun`, the engine suspends the
|
|
7
|
+
* whole run and journals the snapshot; resuming the run re-enters the handler
|
|
8
|
+
* so it can call `runner.resume(...)`. The per-agent suspend nests inside the
|
|
9
|
+
* per-workflow suspend.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A structural view of a `@strav/brain` `SuspendedRun`. */
|
|
13
|
+
export interface SuspendedRunLike {
|
|
14
|
+
status: 'suspended'
|
|
15
|
+
state: unknown
|
|
16
|
+
pendingToolCalls: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Structurally detect a brain `SuspendedRun` without importing `@strav/brain`. */
|
|
20
|
+
export function isSuspendedRun(value: unknown): value is SuspendedRunLike {
|
|
21
|
+
if (typeof value !== 'object' || value === null) return false
|
|
22
|
+
const v = value as Record<string, unknown>
|
|
23
|
+
return v.status === 'suspended' && 'state' in v && 'pendingToolCalls' in v
|
|
24
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { StravError } from '@strav/kernel'
|
|
2
|
+
|
|
3
|
+
/** Base error for the durable execution engine. */
|
|
4
|
+
export class DurableError extends StravError {}
|
|
5
|
+
|
|
6
|
+
/** Thrown when a run id does not exist. */
|
|
7
|
+
export class RunNotFoundError extends DurableError {
|
|
8
|
+
constructor(public readonly runId: number) {
|
|
9
|
+
super(`Durable run ${runId} not found.`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when a workflow name has no registered definition in this process. */
|
|
14
|
+
export class WorkflowNotRegisteredError extends DurableError {
|
|
15
|
+
constructor(public readonly workflowName: string) {
|
|
16
|
+
super(
|
|
17
|
+
`Durable workflow "${workflowName}" is not registered. ` +
|
|
18
|
+
`Import the module that defines it before starting or advancing a run.`
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DurableWorkflow } from './builder.ts'
|
|
2
|
+
import { registry } from './registry.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a durable workflow and register it under `name`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* export const milestone = durable('milestone')
|
|
9
|
+
* .step('discover', async (ctx) => discover(ctx.input))
|
|
10
|
+
* .step('ship', async (ctx) => ship(ctx))
|
|
11
|
+
*/
|
|
12
|
+
export function durable(name: string): DurableWorkflow {
|
|
13
|
+
const workflow = new DurableWorkflow(name)
|
|
14
|
+
registry.register(workflow)
|
|
15
|
+
return workflow
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export { durable } from './helpers.ts'
|
|
2
|
+
export { DurableWorkflow } from './builder.ts'
|
|
3
|
+
export { Durable } from './durable.ts'
|
|
4
|
+
export { registry } from './registry.ts'
|
|
5
|
+
export { WorkflowRun } from './models/workflow_run.ts'
|
|
6
|
+
export { runMachine } from './models/run_machine.ts'
|
|
7
|
+
export { default as DurableProvider } from './providers/durable_provider.ts'
|
|
8
|
+
export { ensureTables } from './schema.ts'
|
|
9
|
+
export {
|
|
10
|
+
DurableError,
|
|
11
|
+
RunNotFoundError,
|
|
12
|
+
WorkflowNotRegisteredError,
|
|
13
|
+
} from './errors.ts'
|
|
14
|
+
export { WorkflowError, CompensationError } from '@strav/workflow'
|
|
15
|
+
export type {
|
|
16
|
+
DurableContext,
|
|
17
|
+
DurableStep,
|
|
18
|
+
DurableStepHandler,
|
|
19
|
+
DurableLoopHandler,
|
|
20
|
+
DurableRouteResolver,
|
|
21
|
+
DurableParallelEntry,
|
|
22
|
+
DurableCompensator,
|
|
23
|
+
DurableStepOptions,
|
|
24
|
+
DurableLoopOptions,
|
|
25
|
+
SequentialStep,
|
|
26
|
+
ParallelStep,
|
|
27
|
+
RouteStep,
|
|
28
|
+
LoopStep,
|
|
29
|
+
SleepStep,
|
|
30
|
+
SignalStep,
|
|
31
|
+
ChildStep,
|
|
32
|
+
RunStatus,
|
|
33
|
+
JournalStatus,
|
|
34
|
+
StartResult,
|
|
35
|
+
ResumeResult,
|
|
36
|
+
RunStatusSnapshot,
|
|
37
|
+
} from './types.ts'
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { sql } from '@strav/database'
|
|
2
|
+
import type { JournalRecord, JournalWrite } from '../types.ts'
|
|
3
|
+
import { parseJson } from '../util.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load every journal entry for a run, keyed by `step_id`. Used (unlocked) at
|
|
7
|
+
* the start of an advance to short-circuit completed steps and sub-units.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadJournal(runId: number): Promise<Map<string, JournalRecord>> {
|
|
10
|
+
const rows = (await sql`
|
|
11
|
+
SELECT "step_id", "status", "result", "error", "attempt"
|
|
12
|
+
FROM "_strav_workflow_journal"
|
|
13
|
+
WHERE "run_id" = ${runId}
|
|
14
|
+
`) as Record<string, unknown>[]
|
|
15
|
+
|
|
16
|
+
const map = new Map<string, JournalRecord>()
|
|
17
|
+
for (const r of rows) {
|
|
18
|
+
map.set(r.step_id as string, {
|
|
19
|
+
stepId: r.step_id as string,
|
|
20
|
+
status: r.status as JournalRecord['status'],
|
|
21
|
+
result: parseJson(r.result),
|
|
22
|
+
error: (r.error as string | null) ?? null,
|
|
23
|
+
attempt: Number(r.attempt),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
return map
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Append journal entries inside a transaction. `ON CONFLICT DO NOTHING` on
|
|
31
|
+
* `UNIQUE (run_id, step_id)` makes a redelivered step idempotent — the first
|
|
32
|
+
* write wins, later writes for the same `step_id` are no-ops.
|
|
33
|
+
*/
|
|
34
|
+
export async function writeJournal(
|
|
35
|
+
trx: { (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown> },
|
|
36
|
+
runId: number,
|
|
37
|
+
writes: JournalWrite[]
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
for (const w of writes) {
|
|
40
|
+
await trx`
|
|
41
|
+
INSERT INTO "_strav_workflow_journal"
|
|
42
|
+
("run_id", "step_id", "status", "result", "error", "attempt")
|
|
43
|
+
VALUES (
|
|
44
|
+
${runId},
|
|
45
|
+
${w.stepId},
|
|
46
|
+
${w.status},
|
|
47
|
+
${w.result === undefined ? null : JSON.stringify(w.result)},
|
|
48
|
+
${w.error ?? null},
|
|
49
|
+
${w.attempt}
|
|
50
|
+
)
|
|
51
|
+
ON CONFLICT ("run_id", "step_id") DO NOTHING
|
|
52
|
+
`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineMachine } from '@strav/machine'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The run-status lifecycle, modeled with `@strav/machine`.
|
|
5
|
+
*
|
|
6
|
+
* pending → running ⇄ suspended
|
|
7
|
+
* running → completed
|
|
8
|
+
* running/suspended → compensating → failed
|
|
9
|
+
* any non-terminal → canceled
|
|
10
|
+
*
|
|
11
|
+
* The engine writes status columns via raw SQL inside its atomic, locked
|
|
12
|
+
* transactions; this machine is the single declarative source of truth for
|
|
13
|
+
* which transitions are legal and powers the `WorkflowRun` `stateful()` model.
|
|
14
|
+
*/
|
|
15
|
+
export const runMachine = defineMachine({
|
|
16
|
+
field: 'status',
|
|
17
|
+
initial: 'pending',
|
|
18
|
+
states: [
|
|
19
|
+
'pending',
|
|
20
|
+
'running',
|
|
21
|
+
'suspended',
|
|
22
|
+
'compensating',
|
|
23
|
+
'completed',
|
|
24
|
+
'failed',
|
|
25
|
+
'canceled',
|
|
26
|
+
],
|
|
27
|
+
transitions: {
|
|
28
|
+
begin: { from: 'pending', to: 'running' },
|
|
29
|
+
suspend: { from: 'running', to: 'suspended' },
|
|
30
|
+
wake: { from: 'suspended', to: 'running' },
|
|
31
|
+
complete: { from: 'running', to: 'completed' },
|
|
32
|
+
rollback: { from: ['running', 'suspended'], to: 'compensating' },
|
|
33
|
+
fail: { from: ['running', 'suspended', 'compensating'], to: 'failed' },
|
|
34
|
+
cancel: {
|
|
35
|
+
from: ['pending', 'running', 'suspended', 'compensating'],
|
|
36
|
+
to: 'canceled',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DateTime } from 'luxon'
|
|
2
|
+
import { BaseModel, cast } from '@strav/database'
|
|
3
|
+
import { stateful } from '@strav/machine'
|
|
4
|
+
import type { Machine } from '@strav/machine'
|
|
5
|
+
import { runMachine } from './run_machine.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ORM model over `_strav_workflow_runs` — the durable, queryable record of a
|
|
9
|
+
* workflow run (Albastr's "pollable open-Turn live record").
|
|
10
|
+
*
|
|
11
|
+
* Mixed with `stateful()` so a run's status is a first-class state machine:
|
|
12
|
+
* `run.is('running')`, `run.availableTransitions()`, `WorkflowRun.inState(...)`.
|
|
13
|
+
* The engine hot path writes status via raw SQL for atomicity; this model is
|
|
14
|
+
* for reads and for application code that wants to poll or query runs.
|
|
15
|
+
*/
|
|
16
|
+
export class WorkflowRun extends stateful(BaseModel, runMachine as Machine) {
|
|
17
|
+
static override get tableName(): string {
|
|
18
|
+
return '_strav_workflow_runs'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare id: number
|
|
22
|
+
declare workflowName: string
|
|
23
|
+
@cast('json') declare input: Record<string, unknown>
|
|
24
|
+
declare status: string
|
|
25
|
+
@cast('json') declare state: Record<string, unknown>
|
|
26
|
+
declare currentStep: number
|
|
27
|
+
declare compensationCursor: number | null
|
|
28
|
+
declare parentRunId: number | null
|
|
29
|
+
declare parentStepId: string | null
|
|
30
|
+
declare awaitingSignal: string | null
|
|
31
|
+
declare wakeAt: DateTime | null
|
|
32
|
+
declare error: string | null
|
|
33
|
+
@cast('json') declare result: Record<string, unknown> | null
|
|
34
|
+
declare createdAt: DateTime
|
|
35
|
+
declare updatedAt: DateTime
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ServiceProvider } from '@strav/kernel'
|
|
2
|
+
import type { Application } from '@strav/kernel'
|
|
3
|
+
import { Durable } from '../durable.ts'
|
|
4
|
+
|
|
5
|
+
export interface DurableProviderOptions {
|
|
6
|
+
/** Whether to auto-create the durable engine tables. Default: `true`. */
|
|
7
|
+
ensureTables?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers the durable execution engine.
|
|
12
|
+
*
|
|
13
|
+
* On boot it creates the engine tables and registers the `durable:advance` /
|
|
14
|
+
* `durable:compensate` queue handlers. Run a `@strav/queue` `Worker` on the
|
|
15
|
+
* durable queue (default name `'durable'`) to actually process runs.
|
|
16
|
+
*/
|
|
17
|
+
export default class DurableProvider extends ServiceProvider {
|
|
18
|
+
readonly name = 'durable'
|
|
19
|
+
override readonly dependencies = ['database', 'queue']
|
|
20
|
+
|
|
21
|
+
constructor(private readonly options?: DurableProviderOptions) {
|
|
22
|
+
super()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override async boot(_app: Application): Promise<void> {
|
|
26
|
+
if (this.options?.ensureTables !== false) {
|
|
27
|
+
await Durable.ensureTables()
|
|
28
|
+
}
|
|
29
|
+
Durable.registerHandlers()
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DurableWorkflow } from './builder.ts'
|
|
2
|
+
import { WorkflowNotRegisteredError } from './errors.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process-wide registry of durable workflow definitions.
|
|
6
|
+
*
|
|
7
|
+
* A `durable:advance` queue job carries only a workflow *name* (not a
|
|
8
|
+
* closure), so the worker process must be able to look the definition up
|
|
9
|
+
* by name. Importing the module that calls `durable(...)` registers it.
|
|
10
|
+
*/
|
|
11
|
+
const workflows = new Map<string, DurableWorkflow>()
|
|
12
|
+
|
|
13
|
+
export const registry = {
|
|
14
|
+
/** Register (or replace) a workflow definition by name. */
|
|
15
|
+
register(workflow: DurableWorkflow): void {
|
|
16
|
+
workflows.set(workflow.name, workflow)
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
/** Look up a workflow definition, throwing if it is not registered. */
|
|
20
|
+
get(name: string): DurableWorkflow {
|
|
21
|
+
const wf = workflows.get(name)
|
|
22
|
+
if (!wf) throw new WorkflowNotRegisteredError(name)
|
|
23
|
+
return wf
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/** Whether a workflow name is registered. */
|
|
27
|
+
has(name: string): boolean {
|
|
28
|
+
return workflows.has(name)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
/** Clear all registrations. For testing only. */
|
|
32
|
+
reset(): void {
|
|
33
|
+
workflows.clear()
|
|
34
|
+
},
|
|
35
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { sql } from '@strav/database'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create the durable engine's two tables if they do not exist.
|
|
5
|
+
*
|
|
6
|
+
* Follows the `@strav/queue` precedent — inline, idempotent DDL rather than
|
|
7
|
+
* a migration file (apps own their migrations directory). Safe to call on
|
|
8
|
+
* every boot. Post-1.0 schema changes must be additive
|
|
9
|
+
* (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`).
|
|
10
|
+
*
|
|
11
|
+
* - `_strav_workflow_runs` — the durable record of a run; the pollable row.
|
|
12
|
+
* - `_strav_workflow_journal` — the per-step checkpoint log;
|
|
13
|
+
* `UNIQUE (run_id, step_id)` is what makes redelivery idempotent.
|
|
14
|
+
*/
|
|
15
|
+
export async function ensureTables(): Promise<void> {
|
|
16
|
+
await sql`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "_strav_workflow_runs" (
|
|
18
|
+
"id" BIGSERIAL PRIMARY KEY,
|
|
19
|
+
"workflow_name" VARCHAR(255) NOT NULL,
|
|
20
|
+
"input" JSONB NOT NULL DEFAULT '{}',
|
|
21
|
+
"status" VARCHAR(32) NOT NULL DEFAULT 'pending',
|
|
22
|
+
"state" JSONB NOT NULL DEFAULT '{}',
|
|
23
|
+
"current_step" INT NOT NULL DEFAULT 0,
|
|
24
|
+
"compensation_cursor" INT,
|
|
25
|
+
"parent_run_id" BIGINT REFERENCES "_strav_workflow_runs"("id") ON DELETE CASCADE,
|
|
26
|
+
"parent_step_id" VARCHAR(512),
|
|
27
|
+
"awaiting_signal" VARCHAR(255),
|
|
28
|
+
"wake_at" TIMESTAMPTZ,
|
|
29
|
+
"error" TEXT,
|
|
30
|
+
"result" JSONB,
|
|
31
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
32
|
+
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
33
|
+
)
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
await sql`
|
|
37
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_wf_runs_status"
|
|
38
|
+
ON "_strav_workflow_runs" ("status")
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
await sql`
|
|
42
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_wf_runs_parent"
|
|
43
|
+
ON "_strav_workflow_runs" ("parent_run_id")
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
await sql`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS "_strav_workflow_journal" (
|
|
48
|
+
"id" BIGSERIAL PRIMARY KEY,
|
|
49
|
+
"run_id" BIGINT NOT NULL REFERENCES "_strav_workflow_runs"("id") ON DELETE CASCADE,
|
|
50
|
+
"step_id" VARCHAR(512) NOT NULL,
|
|
51
|
+
"status" VARCHAR(16) NOT NULL DEFAULT 'completed',
|
|
52
|
+
"result" JSONB,
|
|
53
|
+
"error" TEXT,
|
|
54
|
+
"attempt" INT NOT NULL DEFAULT 1,
|
|
55
|
+
"completed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
56
|
+
UNIQUE ("run_id", "step_id")
|
|
57
|
+
)
|
|
58
|
+
`
|
|
59
|
+
|
|
60
|
+
await sql`
|
|
61
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_wf_journal_run"
|
|
62
|
+
ON "_strav_workflow_journal" ("run_id")
|
|
63
|
+
`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Drop the durable engine's tables. For testing only. */
|
|
67
|
+
export async function dropTables(): Promise<void> {
|
|
68
|
+
await sql`DROP TABLE IF EXISTS "_strav_workflow_journal"`
|
|
69
|
+
await sql`DROP TABLE IF EXISTS "_strav_workflow_runs"`
|
|
70
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { WorkflowContext } from '@strav/workflow'
|
|
2
|
+
|
|
3
|
+
// ── Run + journal status ────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/** Lifecycle status of a durable workflow run. */
|
|
6
|
+
export type RunStatus =
|
|
7
|
+
| 'pending'
|
|
8
|
+
| 'running'
|
|
9
|
+
| 'suspended'
|
|
10
|
+
| 'compensating'
|
|
11
|
+
| 'completed'
|
|
12
|
+
| 'failed'
|
|
13
|
+
| 'canceled'
|
|
14
|
+
|
|
15
|
+
/** Status of a single journal entry. */
|
|
16
|
+
export type JournalStatus = 'completed' | 'failed'
|
|
17
|
+
|
|
18
|
+
// ── Durable context ─────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Context passed to durable step handlers. Extends the `@strav/workflow`
|
|
22
|
+
* context (`input` + `results`) with durable-only accessors, so a plain
|
|
23
|
+
* `@strav/workflow` handler is copy-paste portable into a durable workflow.
|
|
24
|
+
*/
|
|
25
|
+
export interface DurableContext extends WorkflowContext {
|
|
26
|
+
/** The durable run id — stable across crashes/restarts. */
|
|
27
|
+
runId: number
|
|
28
|
+
/** The current retry attempt for the executing step (1-based). */
|
|
29
|
+
attempt: number
|
|
30
|
+
/** The name of the step currently executing. */
|
|
31
|
+
stepName: string
|
|
32
|
+
/** Read a completed step (or signal) result by name. */
|
|
33
|
+
signal<T = unknown>(name: string): T | undefined
|
|
34
|
+
/**
|
|
35
|
+
* The payload passed to the most recent `Durable.resume()` for this run.
|
|
36
|
+
* Used by a brain-agent step to resume a journaled `SuspendedRun`.
|
|
37
|
+
*/
|
|
38
|
+
resumeData<T = unknown>(): T | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Handler signatures ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Handler for sequential, parallel, and route steps. */
|
|
44
|
+
export type DurableStepHandler = (ctx: DurableContext) => Promise<unknown>
|
|
45
|
+
|
|
46
|
+
/** Handler for loop steps — receives the iteration input + context. */
|
|
47
|
+
export type DurableLoopHandler = (input: unknown, ctx: DurableContext) => Promise<unknown>
|
|
48
|
+
|
|
49
|
+
/** Route resolver — returns the branch key to dispatch to. */
|
|
50
|
+
export type DurableRouteResolver = (ctx: DurableContext) => string | Promise<string>
|
|
51
|
+
|
|
52
|
+
/** Compensation handler for saga-style rollback. */
|
|
53
|
+
export type DurableCompensator = (ctx: DurableContext) => Promise<void>
|
|
54
|
+
|
|
55
|
+
/** A single entry in a parallel step. */
|
|
56
|
+
export interface DurableParallelEntry {
|
|
57
|
+
name: string
|
|
58
|
+
handler: DurableStepHandler
|
|
59
|
+
compensate?: DurableCompensator
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Step options ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export interface DurableStepOptions {
|
|
65
|
+
/** Saga compensation — run in reverse order if a later step fails. */
|
|
66
|
+
compensate?: DurableCompensator
|
|
67
|
+
/** Max total attempts before the step is declared failed. Default 3. */
|
|
68
|
+
maxRetries?: number
|
|
69
|
+
/** Backoff strategy between retries. Default 'exponential'. */
|
|
70
|
+
retryBackoff?: 'exponential' | 'linear'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DurableLoopOptions {
|
|
74
|
+
maxIterations: number
|
|
75
|
+
until?: (result: unknown, iteration: number) => boolean
|
|
76
|
+
feedback?: (result: unknown) => unknown
|
|
77
|
+
mapInput?: (ctx: DurableContext) => unknown
|
|
78
|
+
maxRetries?: number
|
|
79
|
+
retryBackoff?: 'exponential' | 'linear'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Step definitions ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface SequentialStep {
|
|
85
|
+
type: 'step'
|
|
86
|
+
name: string
|
|
87
|
+
handler: DurableStepHandler
|
|
88
|
+
compensate?: DurableCompensator
|
|
89
|
+
maxRetries: number
|
|
90
|
+
retryBackoff: 'exponential' | 'linear'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ParallelStep {
|
|
94
|
+
type: 'parallel'
|
|
95
|
+
name: string
|
|
96
|
+
entries: DurableParallelEntry[]
|
|
97
|
+
maxRetries: number
|
|
98
|
+
retryBackoff: 'exponential' | 'linear'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RouteStep {
|
|
102
|
+
type: 'route'
|
|
103
|
+
name: string
|
|
104
|
+
resolver: DurableRouteResolver
|
|
105
|
+
branches: Record<string, DurableStepHandler>
|
|
106
|
+
maxRetries: number
|
|
107
|
+
retryBackoff: 'exponential' | 'linear'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LoopStep {
|
|
111
|
+
type: 'loop'
|
|
112
|
+
name: string
|
|
113
|
+
handler: DurableLoopHandler
|
|
114
|
+
maxIterations: number
|
|
115
|
+
until?: (result: unknown, iteration: number) => boolean
|
|
116
|
+
feedback?: (result: unknown) => unknown
|
|
117
|
+
mapInput?: (ctx: DurableContext) => unknown
|
|
118
|
+
maxRetries: number
|
|
119
|
+
retryBackoff: 'exponential' | 'linear'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Durable timer — suspends the run and resumes after `duration`. */
|
|
123
|
+
export interface SleepStep {
|
|
124
|
+
type: 'sleep'
|
|
125
|
+
name: string
|
|
126
|
+
duration: number | Date
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Human-in-the-loop pause — suspends until `Durable.resume()` delivers the signal. */
|
|
130
|
+
export interface SignalStep {
|
|
131
|
+
type: 'signal'
|
|
132
|
+
name: string
|
|
133
|
+
signal: string
|
|
134
|
+
timeout?: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Spawn an independently durable child workflow and wait for it. */
|
|
138
|
+
export interface ChildStep {
|
|
139
|
+
type: 'child'
|
|
140
|
+
name: string
|
|
141
|
+
childName: string
|
|
142
|
+
mapInput?: (ctx: DurableContext) => Record<string, unknown>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type DurableStep =
|
|
146
|
+
| SequentialStep
|
|
147
|
+
| ParallelStep
|
|
148
|
+
| RouteStep
|
|
149
|
+
| LoopStep
|
|
150
|
+
| SleepStep
|
|
151
|
+
| SignalStep
|
|
152
|
+
| ChildStep
|
|
153
|
+
|
|
154
|
+
// ── Public result shapes ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export interface StartResult {
|
|
157
|
+
runId: number
|
|
158
|
+
status: RunStatus
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ResumeResult {
|
|
162
|
+
/** Whether the signal was accepted (run suspended on a matching signal). */
|
|
163
|
+
accepted: boolean
|
|
164
|
+
status: RunStatus
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface RunStatusSnapshot {
|
|
168
|
+
runId: number
|
|
169
|
+
workflowName: string
|
|
170
|
+
status: RunStatus
|
|
171
|
+
currentStep: number
|
|
172
|
+
totalSteps: number
|
|
173
|
+
awaitingSignal: string | null
|
|
174
|
+
wakeAt: string | null
|
|
175
|
+
results: Record<string, unknown>
|
|
176
|
+
error: string | null
|
|
177
|
+
parentRunId: number | null
|
|
178
|
+
children: RunStatusSnapshot[]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Internal records ────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/** A hydrated `_strav_workflow_runs` row (snake_case columns → camelCase). */
|
|
184
|
+
export interface RunRow {
|
|
185
|
+
id: number
|
|
186
|
+
workflowName: string
|
|
187
|
+
input: Record<string, unknown>
|
|
188
|
+
status: RunStatus
|
|
189
|
+
state: Record<string, unknown>
|
|
190
|
+
currentStep: number
|
|
191
|
+
compensationCursor: number | null
|
|
192
|
+
parentRunId: number | null
|
|
193
|
+
parentStepId: string | null
|
|
194
|
+
awaitingSignal: string | null
|
|
195
|
+
wakeAt: Date | null
|
|
196
|
+
error: string | null
|
|
197
|
+
result: Record<string, unknown> | null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A hydrated `_strav_workflow_journal` row. */
|
|
201
|
+
export interface JournalRecord {
|
|
202
|
+
stepId: string
|
|
203
|
+
status: JournalStatus
|
|
204
|
+
result: unknown
|
|
205
|
+
error: string | null
|
|
206
|
+
attempt: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A pending journal write produced by the step driver. */
|
|
210
|
+
export interface JournalWrite {
|
|
211
|
+
stepId: string
|
|
212
|
+
status: JournalStatus
|
|
213
|
+
result?: unknown
|
|
214
|
+
error?: string
|
|
215
|
+
attempt: number
|
|
216
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a JSONB column value. Bun's SQL driver may return a JSONB column as
|
|
3
|
+
* either an already-parsed object or a raw string depending on context — this
|
|
4
|
+
* normalizes both. `null`/`undefined` pass through unchanged.
|
|
5
|
+
*/
|
|
6
|
+
export function parseJson<T = unknown>(value: unknown): T {
|
|
7
|
+
if (value == null) return value as T
|
|
8
|
+
if (typeof value === 'string') {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(value) as T
|
|
11
|
+
} catch {
|
|
12
|
+
return value as T
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return value as T
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compute the retry backoff delay (ms) for a given attempt number. */
|
|
19
|
+
export function backoffDelay(
|
|
20
|
+
attempt: number,
|
|
21
|
+
strategy: 'exponential' | 'linear'
|
|
22
|
+
): number {
|
|
23
|
+
if (strategy === 'linear') return attempt * 5_000
|
|
24
|
+
return Math.pow(2, attempt) * 1_000 + Math.random() * 1_000
|
|
25
|
+
}
|