@strav/durable 0.4.31 → 1.0.0-alpha.8

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/types.ts CHANGED
@@ -1,216 +1,76 @@
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
1
  /**
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.
2
+ * Public types for durable execution.
3
+ *
4
+ * A durable workflow is a *named*, registered definition: handlers are
5
+ * keyed by step name so the runner can re-enter them across processes
6
+ * after a crash. Apps don't pass closures into `start()` — they pass
7
+ * a workflow name + input.
8
+ *
9
+ * `DurableContext` is what each step handler receives. `results` is the
10
+ * accumulated typed return of every prior step; `runId` lets apps log
11
+ * + correlate with the durable record; `attempt` is 1-based and lets
12
+ * apps switch behavior on retry (e.g. tighten timeouts).
13
+ *
14
+ * `RunSnapshot` is what `DurableRunner.find(runId)` returns — the
15
+ * shape app code uses to poll a run from outside (UI status pages, the
16
+ * `durable:status` CLI command in a later slice).
24
17
  */
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
18
 
41
- // ── Handler signatures ──────────────────────────────────────────────────────
19
+ export type RunStatus = 'pending' | 'running' | 'compensating' | 'completed' | 'failed'
42
20
 
43
- /** Handler for sequential, parallel, and route steps. */
44
- export type DurableStepHandler = (ctx: DurableContext) => Promise<unknown>
21
+ export interface DurableContext {
22
+ /** Workflow input the object passed to `DurableRunner.start(name, input)`. */
23
+ readonly input: Record<string, unknown>
24
+ /** Results from every prior step, keyed by step name. */
25
+ readonly results: Record<string, unknown>
26
+ /** Durable run id (the row PK). Useful for logging / correlation. */
27
+ readonly runId: string
28
+ /** 1-based retry counter for this step. `1` on first run. */
29
+ readonly attempt: number
30
+ }
45
31
 
46
- /** Handler for loop steps — receives the iteration input + context. */
47
- export type DurableLoopHandler = (input: unknown, ctx: DurableContext) => Promise<unknown>
32
+ export interface RunSnapshot {
33
+ id: string
34
+ workflowName: string
35
+ status: RunStatus
36
+ input: Record<string, unknown>
37
+ results: Record<string, unknown>
38
+ currentStep: number
39
+ result: Record<string, unknown> | null
40
+ error: string | null
41
+ createdAt: Date
42
+ updatedAt: Date
43
+ }
48
44
 
49
- /** Route resolverreturns the branch key to dispatch to. */
50
- export type DurableRouteResolver = (ctx: DurableContext) => string | Promise<string>
45
+ /** Step handlerreceives the durable context, returns the step's result. */
46
+ export type DurableStepHandler = (ctx: DurableContext) => Promise<unknown>
51
47
 
52
- /** Compensation handler for saga-style rollback. */
48
+ /** Saga rollback handler. Same context shape; return ignored. */
53
49
  export type DurableCompensator = (ctx: DurableContext) => Promise<void>
54
50
 
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
51
  export interface DurableStepOptions {
65
- /** Saga compensation — run in reverse order if a later step fails. */
66
52
  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'
53
+ /** Hard cap on attempts. Default `3`. Includes the first attempt. */
54
+ maxAttempts?: number
55
+ /**
56
+ * Backoff function — input is the attempt number that just failed
57
+ * (1-based), output is the delay in seconds before the next attempt.
58
+ * Default: exponential `2 ** attempt` capped at 60s.
59
+ */
60
+ backoff?: (failedAttempt: number) => number
80
61
  }
81
62
 
82
- // ── Step definitions ────────────────────────────────────────────────────────
83
-
84
- export interface SequentialStep {
63
+ /**
64
+ * Internal step record. Apps don't construct this directly — the
65
+ * `DurableWorkflow` builder pushes one per `.step()` call. Exported
66
+ * so tests and introspection tools can read the plan from
67
+ * `workflow.steps`.
68
+ */
69
+ export interface DurableStep {
85
70
  type: 'step'
86
71
  name: string
87
72
  handler: DurableStepHandler
88
73
  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
74
+ maxAttempts: number
75
+ backoff: (failedAttempt: number) => number
216
76
  }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `WorkflowRegistry` — name → `DurableWorkflow` lookup used by the
3
+ * advance / compensate handlers.
4
+ *
5
+ * Mirrors `JobRegistry` from `@strav/queue`: explicit registration on
6
+ * an instance, no module-singleton state. Each app builds one
7
+ * registry, registers its workflows, and hands it to the runner.
8
+ *
9
+ * Duplicate-name registration throws — the runner journals steps by
10
+ * (run, step name), so silently shadowing a registered workflow with
11
+ * a different shape would corrupt replays.
12
+ */
13
+
14
+ import { DurableError, WorkflowNotRegisteredError } from './durable_error.ts'
15
+ import type { DurableWorkflow } from './durable_workflow.ts'
16
+
17
+ export class WorkflowRegistry {
18
+ private readonly workflows = new Map<string, DurableWorkflow>()
19
+
20
+ register(workflow: DurableWorkflow): this {
21
+ if (this.workflows.has(workflow.name)) {
22
+ throw new DurableError(
23
+ `WorkflowRegistry: workflow "${workflow.name}" is already registered. Refusing to shadow — re-name the workflow or restart the registry.`,
24
+ )
25
+ }
26
+ this.workflows.set(workflow.name, workflow)
27
+ return this
28
+ }
29
+
30
+ registerAll(workflows: readonly DurableWorkflow[]): this {
31
+ for (const wf of workflows) this.register(wf)
32
+ return this
33
+ }
34
+
35
+ /** Look up a workflow by name. Throws when missing. */
36
+ get(name: string): DurableWorkflow {
37
+ const wf = this.workflows.get(name)
38
+ if (!wf) throw new WorkflowNotRegisteredError(name, [...this.workflows.keys()])
39
+ return wf
40
+ }
41
+
42
+ has(name: string): boolean {
43
+ return this.workflows.has(name)
44
+ }
45
+
46
+ names(): string[] {
47
+ return [...this.workflows.keys()]
48
+ }
49
+ }
package/CHANGELOG.md DELETED
@@ -1,26 +0,0 @@
1
- # @strav/durable
2
-
3
- ## 0.4.26
4
-
5
- Initial release — durable, crash-resumable workflow execution.
6
-
7
- - `durable(name)` builder mirroring the `@strav/workflow` authoring API
8
- (`.step` / `.parallel` / `.route` / `.loop` + `compensate`), plus durable-only
9
- step types `.sleep`, `.waitForSignal`, and `.childWorkflow`.
10
- - Explicit-journal execution model (DBOS/Inngest style, no determinism sandbox):
11
- every step is checkpointed to `_strav_workflow_journal`; `UNIQUE (run_id, step_id)`
12
- makes redelivery idempotent.
13
- - Queue-driven progression — one top-level step = one `durable:advance`
14
- `@strav/queue` job; the journal write + run-row update + next-job enqueue
15
- commit in a single transaction, so the engine inherits crash-safety from the
16
- queue. A workflow killed mid-execution resumes from the first incomplete step.
17
- - Suspend/resume: `waitForSignal` parks a run for an unbounded time holding no
18
- process; `Durable.resume()` delivers the signal.
19
- - Durable timers via `.sleep` (survives process restarts).
20
- - Independently durable, parent-linked child workflows.
21
- - Journaled saga compensation — rollback is itself crash-safe.
22
- - Composes with `@strav/brain` — a step returning a `SuspendedRun` suspends the
23
- run; resume re-enters the step (duck-typed, no `@strav/brain` dependency).
24
- - `WorkflowRun` — a `stateful()` ORM model over the run record, with a
25
- `@strav/machine` run-status lifecycle.
26
- - `Durable.start` / `resume` / `status` / `list` / `cancel` / `recover`.
package/src/builder.ts DELETED
@@ -1,158 +0,0 @@
1
- import { DurableError } from './errors.ts'
2
- import type {
3
- DurableStep,
4
- DurableStepHandler,
5
- DurableLoopHandler,
6
- DurableRouteResolver,
7
- DurableParallelEntry,
8
- DurableStepOptions,
9
- DurableLoopOptions,
10
- } from './types.ts'
11
-
12
- const DEFAULT_MAX_RETRIES = 3
13
-
14
- /**
15
- * Durable workflow builder.
16
- *
17
- * Mirrors the `@strav/workflow` authoring API (`.step` / `.parallel` /
18
- * `.route` / `.loop` + `compensate`) so plain workflow code is copy-paste
19
- * portable, and adds durable-only step types: `.sleep`, `.waitForSignal`,
20
- * and `.childWorkflow`.
21
- *
22
- * Built workflows are flat ordered step lists — there is no re-runnable
23
- * function body, so durability needs no determinism sandbox.
24
- *
25
- * @example
26
- * durable('milestone')
27
- * .step('discover', async (ctx) => discover(ctx.input))
28
- * .parallel('build', [
29
- * { name: 'api', handler: async (ctx) => buildApi(ctx) },
30
- * { name: 'ui', handler: async (ctx) => buildUi(ctx) },
31
- * ])
32
- * .waitForSignal('signoff', 'founder-signoff')
33
- * .step('ship', async (ctx) => ship(ctx))
34
- */
35
- export class DurableWorkflow {
36
- readonly name: string
37
- private readonly _steps: DurableStep[] = []
38
- private readonly _names = new Set<string>()
39
-
40
- constructor(name: string) {
41
- this.name = name
42
- }
43
-
44
- /** The ordered, immutable step list. */
45
- get steps(): readonly DurableStep[] {
46
- return this._steps
47
- }
48
-
49
- private claim(name: string): void {
50
- if (this._names.has(name)) {
51
- throw new DurableError(
52
- `Duplicate step name "${name}" in durable workflow "${this.name}".`
53
- )
54
- }
55
- this._names.add(name)
56
- }
57
-
58
- /** Add a sequential step. Its result is stored in `ctx.results[name]`. */
59
- step(name: string, handler: DurableStepHandler, options?: DurableStepOptions): this {
60
- this.claim(name)
61
- this._steps.push({
62
- type: 'step',
63
- name,
64
- handler,
65
- compensate: options?.compensate,
66
- maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
67
- retryBackoff: options?.retryBackoff ?? 'exponential',
68
- })
69
- return this
70
- }
71
-
72
- /** Run multiple handlers in parallel; each result is stored under its entry name. */
73
- parallel(
74
- name: string,
75
- entries: DurableParallelEntry[],
76
- options?: Pick<DurableStepOptions, 'maxRetries' | 'retryBackoff'>
77
- ): this {
78
- this.claim(name)
79
- if (entries.length === 0) {
80
- throw new DurableError(`Parallel step "${name}" must have at least one entry.`)
81
- }
82
- this._steps.push({
83
- type: 'parallel',
84
- name,
85
- entries,
86
- maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
87
- retryBackoff: options?.retryBackoff ?? 'exponential',
88
- })
89
- return this
90
- }
91
-
92
- /** Route to a branch based on a resolver's return value. */
93
- route(
94
- name: string,
95
- resolver: DurableRouteResolver,
96
- branches: Record<string, DurableStepHandler>,
97
- options?: Pick<DurableStepOptions, 'maxRetries' | 'retryBackoff'>
98
- ): this {
99
- this.claim(name)
100
- this._steps.push({
101
- type: 'route',
102
- name,
103
- resolver,
104
- branches,
105
- maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES,
106
- retryBackoff: options?.retryBackoff ?? 'exponential',
107
- })
108
- return this
109
- }
110
-
111
- /** Run a handler in a loop until a condition is met or max iterations reached. */
112
- loop(name: string, handler: DurableLoopHandler, options: DurableLoopOptions): this {
113
- this.claim(name)
114
- this._steps.push({
115
- type: 'loop',
116
- name,
117
- handler,
118
- maxIterations: options.maxIterations,
119
- until: options.until,
120
- feedback: options.feedback,
121
- mapInput: options.mapInput,
122
- maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
123
- retryBackoff: options.retryBackoff ?? 'exponential',
124
- })
125
- return this
126
- }
127
-
128
- /** Durable timer — suspend the run and resume after `duration` (ms or a Date). */
129
- sleep(name: string, duration: number | Date): this {
130
- this.claim(name)
131
- this._steps.push({ type: 'sleep', name, duration })
132
- return this
133
- }
134
-
135
- /**
136
- * Suspend the run until `Durable.resume(runId, signal, data)` is called.
137
- * Holds no process while suspended — resumes exactly, even days later.
138
- */
139
- waitForSignal(name: string, signal: string, options?: { timeout?: number }): this {
140
- this.claim(name)
141
- this._steps.push({ type: 'signal', name, signal, timeout: options?.timeout })
142
- return this
143
- }
144
-
145
- /**
146
- * Spawn an independently durable child workflow and wait for it to finish.
147
- * The child result is stored in `ctx.results[name]`.
148
- */
149
- childWorkflow(
150
- name: string,
151
- childName: string,
152
- mapInput?: (ctx: import('./types.ts').DurableContext) => Record<string, unknown>
153
- ): this {
154
- this.claim(name)
155
- this._steps.push({ type: 'child', name, childName, mapInput })
156
- return this
157
- }
158
- }
package/src/config.ts DELETED
@@ -1,36 +0,0 @@
1
- /** Engine-wide configuration for `@strav/durable`. */
2
- export interface DurableConfig {
3
- /**
4
- * The `@strav/queue` queue name durable jobs are dispatched on. Run a
5
- * `Worker({ queue })` on this name to process durable workflows. Keeping
6
- * it separate from app jobs lets durable work be scaled independently.
7
- */
8
- queue: string
9
- /**
10
- * Per-job timeout (ms) for `durable:advance` / `durable:compensate` jobs.
11
- * Must comfortably exceed the slowest single step (e.g. an LLM call).
12
- */
13
- jobTimeout: number
14
- /**
15
- * Queue-level max attempts for durable jobs. This insures against *process
16
- * crashes* only — application-level step retries are handled by the engine
17
- * via the journal `attempt` count, independently of this.
18
- */
19
- maxAttempts: number
20
- }
21
-
22
- const config: DurableConfig = {
23
- queue: 'durable',
24
- jobTimeout: 600_000,
25
- maxAttempts: 5,
26
- }
27
-
28
- /** Read the current durable engine configuration. */
29
- export function getConfig(): DurableConfig {
30
- return config
31
- }
32
-
33
- /** Override durable engine configuration. Call before booting workers. */
34
- export function configureDurable(patch: Partial<DurableConfig>): void {
35
- Object.assign(config, patch)
36
- }