@strav/durable 0.4.31 → 1.0.0-alpha.9
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/package.json +18 -37
- package/src/define_durable.ts +29 -0
- package/src/durable_advance_job.ts +38 -0
- package/src/durable_compensate_job.ts +33 -0
- package/src/durable_error.ts +38 -0
- package/src/durable_provider.ts +91 -0
- package/src/durable_runner.ts +395 -0
- package/src/durable_workflow.ts +97 -0
- package/src/index.ts +25 -26
- package/src/journal_schema.ts +53 -0
- package/src/runs_schema.ts +38 -0
- package/src/types.ts +58 -198
- package/src/workflow_registry.ts +49 -0
- package/CHANGELOG.md +0 -26
- package/src/builder.ts +0 -158
- package/src/config.ts +0 -36
- package/src/durable.ts +0 -268
- package/src/engine/advance_handler.ts +0 -154
- package/src/engine/compensate_handler.ts +0 -70
- package/src/engine/compensation_driver.ts +0 -61
- package/src/engine/context.ts +0 -36
- package/src/engine/enqueue.ts +0 -62
- package/src/engine/finalize.ts +0 -111
- package/src/engine/index.ts +0 -20
- package/src/engine/run_store.ts +0 -42
- package/src/engine/step_driver.ts +0 -291
- package/src/engine/suspended_run.ts +0 -24
- package/src/errors.ts +0 -21
- package/src/helpers.ts +0 -16
- package/src/models/index.ts +0 -3
- package/src/models/journal.ts +0 -54
- package/src/models/run_machine.ts +0 -39
- package/src/models/workflow_run.ts +0 -36
- package/src/providers/durable_provider.ts +0 -31
- package/src/providers/index.ts +0 -2
- package/src/registry.ts +0 -35
- package/src/schema.ts +0 -70
- package/src/util.ts +0 -25
- package/tsconfig.json +0 -5
package/src/engine/finalize.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { transaction } from '@strav/database'
|
|
2
|
-
import type { RunRow } from '../types.ts'
|
|
3
|
-
import { writeJournal } from '../models/journal.ts'
|
|
4
|
-
import { enqueueAdvance, enqueueCompensate } from './enqueue.ts'
|
|
5
|
-
import { lockRun, type Tx } from './run_store.ts'
|
|
6
|
-
|
|
7
|
-
/** Merge a result patch into a run's accumulated state. */
|
|
8
|
-
export function applyPatch(
|
|
9
|
-
state: Record<string, unknown>,
|
|
10
|
-
patch: Record<string, unknown>
|
|
11
|
-
): Record<string, unknown> {
|
|
12
|
-
return { ...state, ...patch }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Transition a run into compensation, rolling back from the step before the
|
|
17
|
-
* one that failed. If nothing was completed before it, fail the run directly.
|
|
18
|
-
*/
|
|
19
|
-
export async function beginCompensation(
|
|
20
|
-
trx: Tx,
|
|
21
|
-
run: RunRow,
|
|
22
|
-
failedStepIndex: number,
|
|
23
|
-
failure: string
|
|
24
|
-
): Promise<void> {
|
|
25
|
-
const cursor = failedStepIndex - 1
|
|
26
|
-
if (cursor < 0) {
|
|
27
|
-
await failRun(trx, run, failure)
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
await trx`
|
|
31
|
-
UPDATE "_strav_workflow_runs"
|
|
32
|
-
SET "status" = 'compensating', "compensation_cursor" = ${cursor},
|
|
33
|
-
"error" = ${failure}, "updated_at" = NOW()
|
|
34
|
-
WHERE "id" = ${run.id}
|
|
35
|
-
`
|
|
36
|
-
await enqueueCompensate(trx, run.id, cursor)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Mark a run failed and, if it is a child, propagate failure to its parent. */
|
|
40
|
-
export async function failRun(trx: Tx, run: RunRow, failure: string): Promise<void> {
|
|
41
|
-
await trx`
|
|
42
|
-
UPDATE "_strav_workflow_runs"
|
|
43
|
-
SET "status" = 'failed', "error" = ${failure}, "updated_at" = NOW()
|
|
44
|
-
WHERE "id" = ${run.id}
|
|
45
|
-
`
|
|
46
|
-
if (run.parentRunId != null) {
|
|
47
|
-
await finalizeChildIntoParent(trx, run, 'failed')
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Complete a run. Opens its own transaction (called as the terminal advance).
|
|
53
|
-
* If the run is a child, fan in: write its result into the parent journal and
|
|
54
|
-
* resume the parent — all in the same transaction, so the parent's
|
|
55
|
-
* continuation job is visible only once the child result is.
|
|
56
|
-
*/
|
|
57
|
-
export async function completeRun(runId: number): Promise<void> {
|
|
58
|
-
await transaction(async (trx: Tx) => {
|
|
59
|
-
const run = await lockRun(trx, runId)
|
|
60
|
-
if (!run || run.status !== 'running') return
|
|
61
|
-
await trx`
|
|
62
|
-
UPDATE "_strav_workflow_runs"
|
|
63
|
-
SET "status" = 'completed', "result" = ${JSON.stringify(run.state)},
|
|
64
|
-
"updated_at" = NOW()
|
|
65
|
-
WHERE "id" = ${runId}
|
|
66
|
-
`
|
|
67
|
-
if (run.parentRunId != null) {
|
|
68
|
-
await finalizeChildIntoParent(trx, run, 'completed')
|
|
69
|
-
}
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Fan a finished child run into its parent. Locks the parent (child→parent
|
|
75
|
-
* lock order is consistent everywhere, so no deadlock).
|
|
76
|
-
*/
|
|
77
|
-
export async function finalizeChildIntoParent(
|
|
78
|
-
trx: Tx,
|
|
79
|
-
childRun: RunRow,
|
|
80
|
-
outcome: 'completed' | 'failed'
|
|
81
|
-
): Promise<void> {
|
|
82
|
-
const parentId = childRun.parentRunId
|
|
83
|
-
const childStepId = childRun.parentStepId
|
|
84
|
-
if (parentId == null || childStepId == null) return
|
|
85
|
-
|
|
86
|
-
const parent = await lockRun(trx, parentId)
|
|
87
|
-
if (!parent || parent.status !== 'suspended') return
|
|
88
|
-
|
|
89
|
-
const parentStepIndex = parent.currentStep
|
|
90
|
-
|
|
91
|
-
if (outcome === 'completed') {
|
|
92
|
-
await writeJournal(trx, parentId, [
|
|
93
|
-
{ stepId: childStepId, status: 'completed', result: childRun.state, attempt: 1 },
|
|
94
|
-
])
|
|
95
|
-
const newState = applyPatch(parent.state, { [childStepId]: childRun.state })
|
|
96
|
-
const next = parentStepIndex + 1
|
|
97
|
-
await trx`
|
|
98
|
-
UPDATE "_strav_workflow_runs"
|
|
99
|
-
SET "status" = 'running', "state" = ${JSON.stringify(newState)},
|
|
100
|
-
"current_step" = ${next}, "updated_at" = NOW()
|
|
101
|
-
WHERE "id" = ${parentId}
|
|
102
|
-
`
|
|
103
|
-
await enqueueAdvance(trx, parentId, next)
|
|
104
|
-
} else {
|
|
105
|
-
const failure = `child workflow "${childRun.workflowName}" failed`
|
|
106
|
-
await writeJournal(trx, parentId, [
|
|
107
|
-
{ stepId: childStepId, status: 'failed', error: failure, attempt: 1 },
|
|
108
|
-
])
|
|
109
|
-
await beginCompensation(trx, parent, parentStepIndex, failure)
|
|
110
|
-
}
|
|
111
|
-
}
|
package/src/engine/index.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export { advanceHandler } from './advance_handler.ts'
|
|
2
|
-
export type { AdvancePayload } from './advance_handler.ts'
|
|
3
|
-
export { compensateHandler } from './compensate_handler.ts'
|
|
4
|
-
export type { CompensatePayload } from './compensate_handler.ts'
|
|
5
|
-
export { runDurableStep } from './step_driver.ts'
|
|
6
|
-
export type { StepOutcome } from './step_driver.ts'
|
|
7
|
-
export { runCompensator } from './compensation_driver.ts'
|
|
8
|
-
export { buildContext } from './context.ts'
|
|
9
|
-
export { isSuspendedRun } from './suspended_run.ts'
|
|
10
|
-
export type { SuspendedRunLike } from './suspended_run.ts'
|
|
11
|
-
export { enqueueAdvance, enqueueCompensate } from './enqueue.ts'
|
|
12
|
-
export { loadRun, lockRun, hydrateRun } from './run_store.ts'
|
|
13
|
-
export type { Tx } from './run_store.ts'
|
|
14
|
-
export {
|
|
15
|
-
applyPatch,
|
|
16
|
-
beginCompensation,
|
|
17
|
-
completeRun,
|
|
18
|
-
failRun,
|
|
19
|
-
finalizeChildIntoParent,
|
|
20
|
-
} from './finalize.ts'
|
package/src/engine/run_store.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { sql } from '@strav/database'
|
|
2
|
-
import type { RunRow } from '../types.ts'
|
|
3
|
-
import { parseJson } from '../util.ts'
|
|
4
|
-
|
|
5
|
-
/** A Bun SQL transaction handle (tagged-template callable). */
|
|
6
|
-
export type Tx = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<unknown>
|
|
7
|
-
|
|
8
|
-
/** Hydrate a raw `_strav_workflow_runs` row into a typed `RunRow`. */
|
|
9
|
-
export function hydrateRun(row: Record<string, unknown>): RunRow {
|
|
10
|
-
return {
|
|
11
|
-
id: Number(row.id),
|
|
12
|
-
workflowName: row.workflow_name as string,
|
|
13
|
-
input: parseJson<Record<string, unknown>>(row.input) ?? {},
|
|
14
|
-
status: row.status as RunRow['status'],
|
|
15
|
-
state: parseJson<Record<string, unknown>>(row.state) ?? {},
|
|
16
|
-
currentStep: Number(row.current_step),
|
|
17
|
-
compensationCursor:
|
|
18
|
-
row.compensation_cursor == null ? null : Number(row.compensation_cursor),
|
|
19
|
-
parentRunId: row.parent_run_id == null ? null : Number(row.parent_run_id),
|
|
20
|
-
parentStepId: (row.parent_step_id as string | null) ?? null,
|
|
21
|
-
awaitingSignal: (row.awaiting_signal as string | null) ?? null,
|
|
22
|
-
wakeAt: (row.wake_at as Date | null) ?? null,
|
|
23
|
-
error: (row.error as string | null) ?? null,
|
|
24
|
-
result: parseJson<Record<string, unknown>>(row.result) ?? null,
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Load a run by id (unlocked read). */
|
|
29
|
-
export async function loadRun(runId: number): Promise<RunRow | null> {
|
|
30
|
-
const rows = (await sql`
|
|
31
|
-
SELECT * FROM "_strav_workflow_runs" WHERE "id" = ${runId}
|
|
32
|
-
`) as Record<string, unknown>[]
|
|
33
|
-
return rows.length > 0 ? hydrateRun(rows[0]!) : null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Load a run `FOR UPDATE` inside a transaction (row-locked). */
|
|
37
|
-
export async function lockRun(trx: Tx, runId: number): Promise<RunRow | null> {
|
|
38
|
-
const rows = (await trx`
|
|
39
|
-
SELECT * FROM "_strav_workflow_runs" WHERE "id" = ${runId} FOR UPDATE
|
|
40
|
-
`) as Record<string, unknown>[]
|
|
41
|
-
return rows.length > 0 ? hydrateRun(rows[0]!) : null
|
|
42
|
-
}
|
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
DurableContext,
|
|
3
|
-
DurableStep,
|
|
4
|
-
JournalRecord,
|
|
5
|
-
JournalWrite,
|
|
6
|
-
} from '../types.ts'
|
|
7
|
-
import { backoffDelay } from '../util.ts'
|
|
8
|
-
import { isSuspendedRun } from './suspended_run.ts'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The result of executing one top-level step. The engine applies it
|
|
12
|
-
* atomically in Phase B (a row-locked transaction).
|
|
13
|
-
*/
|
|
14
|
-
export type StepOutcome =
|
|
15
|
-
/** Step (and all sub-units) completed — journal results and move forward. */
|
|
16
|
-
| { kind: 'advance'; journal: JournalWrite[]; resultPatch: Record<string, unknown> }
|
|
17
|
-
/** A durable timer — journal, move forward, enqueue a delayed continuation. */
|
|
18
|
-
| { kind: 'sleep'; journal: JournalWrite[]; resultPatch: Record<string, unknown>; wakeAt: Date }
|
|
19
|
-
/** Suspend awaiting an external signal (human-in-the-loop). */
|
|
20
|
-
| { kind: 'suspend-signal'; signal: string }
|
|
21
|
-
/** Suspend on a brain agent `SuspendedRun` — resume re-enters this step. */
|
|
22
|
-
| { kind: 'suspend-agent'; stepName: string; snapshot: unknown }
|
|
23
|
-
/** Spawn a child workflow and wait for it. */
|
|
24
|
-
| { kind: 'await-child'; childName: string; childInput: Record<string, unknown>; childStepId: string }
|
|
25
|
-
/** Step failed but retries remain — re-enqueue the same step with backoff. */
|
|
26
|
-
| { kind: 'retry'; journal: JournalWrite[]; attempt: number; backoffMs: number; failure: string }
|
|
27
|
-
/** Step failed terminally — begin saga compensation. */
|
|
28
|
-
| { kind: 'compensate'; journal: JournalWrite[]; failure: string }
|
|
29
|
-
|
|
30
|
-
type RetryableStep = {
|
|
31
|
-
name: string
|
|
32
|
-
maxRetries: number
|
|
33
|
-
retryBackoff: 'exponential' | 'linear'
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function message(err: unknown): string {
|
|
37
|
-
return err instanceof Error ? err.message : String(err)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Decide between retry and compensation when a step's handler throws. */
|
|
41
|
-
function failureOutcome(
|
|
42
|
-
step: RetryableStep,
|
|
43
|
-
attempt: number,
|
|
44
|
-
err: unknown,
|
|
45
|
-
partialJournal: JournalWrite[]
|
|
46
|
-
): StepOutcome {
|
|
47
|
-
const failure = message(err)
|
|
48
|
-
if (attempt < step.maxRetries) {
|
|
49
|
-
return {
|
|
50
|
-
kind: 'retry',
|
|
51
|
-
journal: partialJournal,
|
|
52
|
-
attempt: attempt + 1,
|
|
53
|
-
backoffMs: backoffDelay(attempt, step.retryBackoff),
|
|
54
|
-
failure,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return {
|
|
58
|
-
kind: 'compensate',
|
|
59
|
-
journal: [
|
|
60
|
-
...partialJournal,
|
|
61
|
-
{ stepId: step.name, status: 'failed', error: failure, attempt },
|
|
62
|
-
],
|
|
63
|
-
failure,
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Execute one top-level step against the journal. Completed sub-units
|
|
69
|
-
* (parallel entries, loop iterations, the route decision) are read back from
|
|
70
|
-
* the journal rather than re-run, so redelivery fast-forwards to the first
|
|
71
|
-
* incomplete unit.
|
|
72
|
-
*/
|
|
73
|
-
export async function runDurableStep(
|
|
74
|
-
step: DurableStep,
|
|
75
|
-
ctx: DurableContext,
|
|
76
|
-
journal: Map<string, JournalRecord>
|
|
77
|
-
): Promise<StepOutcome> {
|
|
78
|
-
switch (step.type) {
|
|
79
|
-
case 'step':
|
|
80
|
-
return runSequential(step, ctx)
|
|
81
|
-
case 'parallel':
|
|
82
|
-
return runParallel(step, ctx, journal)
|
|
83
|
-
case 'route':
|
|
84
|
-
return runRoute(step, ctx, journal)
|
|
85
|
-
case 'loop':
|
|
86
|
-
return runLoop(step, ctx, journal)
|
|
87
|
-
case 'sleep':
|
|
88
|
-
return runSleep(step)
|
|
89
|
-
case 'signal':
|
|
90
|
-
return { kind: 'suspend-signal', signal: step.signal }
|
|
91
|
-
case 'child':
|
|
92
|
-
return runChild(step, ctx)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── step ────────────────────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
async function runSequential(
|
|
99
|
-
step: Extract<DurableStep, { type: 'step' }>,
|
|
100
|
-
ctx: DurableContext
|
|
101
|
-
): Promise<StepOutcome> {
|
|
102
|
-
try {
|
|
103
|
-
const result = await step.handler(ctx)
|
|
104
|
-
if (isSuspendedRun(result)) {
|
|
105
|
-
return { kind: 'suspend-agent', stepName: step.name, snapshot: result }
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
kind: 'advance',
|
|
109
|
-
journal: [{ stepId: step.name, status: 'completed', result, attempt: ctx.attempt }],
|
|
110
|
-
resultPatch: { [step.name]: result },
|
|
111
|
-
}
|
|
112
|
-
} catch (err) {
|
|
113
|
-
return failureOutcome(step, ctx.attempt, err, [])
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── parallel ────────────────────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
async function runParallel(
|
|
120
|
-
step: Extract<DurableStep, { type: 'parallel' }>,
|
|
121
|
-
ctx: DurableContext,
|
|
122
|
-
journal: Map<string, JournalRecord>
|
|
123
|
-
): Promise<StepOutcome> {
|
|
124
|
-
const settled = await Promise.all(
|
|
125
|
-
step.entries.map(async entry => {
|
|
126
|
-
const jid = `${step.name}#${entry.name}`
|
|
127
|
-
const existing = journal.get(jid)
|
|
128
|
-
if (existing?.status === 'completed') {
|
|
129
|
-
return { entry, ok: true as const, result: existing.result, fresh: false }
|
|
130
|
-
}
|
|
131
|
-
try {
|
|
132
|
-
const result = await entry.handler(ctx)
|
|
133
|
-
return { entry, ok: true as const, result, fresh: true }
|
|
134
|
-
} catch (err) {
|
|
135
|
-
return { entry, ok: false as const, err, fresh: true }
|
|
136
|
-
}
|
|
137
|
-
})
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
const writes: JournalWrite[] = []
|
|
141
|
-
const resultPatch: Record<string, unknown> = {}
|
|
142
|
-
let firstError: unknown
|
|
143
|
-
|
|
144
|
-
for (const s of settled) {
|
|
145
|
-
if (s.ok) {
|
|
146
|
-
resultPatch[s.entry.name] = s.result
|
|
147
|
-
if (s.fresh) {
|
|
148
|
-
writes.push({
|
|
149
|
-
stepId: `${step.name}#${s.entry.name}`,
|
|
150
|
-
status: 'completed',
|
|
151
|
-
result: s.result,
|
|
152
|
-
attempt: ctx.attempt,
|
|
153
|
-
})
|
|
154
|
-
}
|
|
155
|
-
} else if (firstError === undefined) {
|
|
156
|
-
firstError = s.err
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (firstError === undefined) {
|
|
161
|
-
writes.push({
|
|
162
|
-
stepId: step.name,
|
|
163
|
-
status: 'completed',
|
|
164
|
-
result: resultPatch,
|
|
165
|
-
attempt: ctx.attempt,
|
|
166
|
-
})
|
|
167
|
-
return { kind: 'advance', journal: writes, resultPatch }
|
|
168
|
-
}
|
|
169
|
-
return failureOutcome(step, ctx.attempt, firstError, writes)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── route ───────────────────────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
async function runRoute(
|
|
175
|
-
step: Extract<DurableStep, { type: 'route' }>,
|
|
176
|
-
ctx: DurableContext,
|
|
177
|
-
journal: Map<string, JournalRecord>
|
|
178
|
-
): Promise<StepOutcome> {
|
|
179
|
-
const routeJid = `${step.name}#route`
|
|
180
|
-
const writes: JournalWrite[] = []
|
|
181
|
-
let routeKey: string
|
|
182
|
-
|
|
183
|
-
const existingRoute = journal.get(routeJid)
|
|
184
|
-
try {
|
|
185
|
-
if (existingRoute?.status === 'completed') {
|
|
186
|
-
routeKey = existingRoute.result as string
|
|
187
|
-
} else {
|
|
188
|
-
routeKey = await step.resolver(ctx)
|
|
189
|
-
writes.push({ stepId: routeJid, status: 'completed', result: routeKey, attempt: ctx.attempt })
|
|
190
|
-
}
|
|
191
|
-
} catch (err) {
|
|
192
|
-
return failureOutcome(step, ctx.attempt, err, [])
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const branch = step.branches[routeKey]
|
|
196
|
-
try {
|
|
197
|
-
let result: unknown = null
|
|
198
|
-
const existingBranch = journal.get(step.name)
|
|
199
|
-
if (existingBranch?.status === 'completed') {
|
|
200
|
-
result = existingBranch.result
|
|
201
|
-
} else if (branch) {
|
|
202
|
-
result = await branch(ctx)
|
|
203
|
-
}
|
|
204
|
-
writes.push({ stepId: step.name, status: 'completed', result, attempt: ctx.attempt })
|
|
205
|
-
return {
|
|
206
|
-
kind: 'advance',
|
|
207
|
-
journal: writes,
|
|
208
|
-
resultPatch: branch ? { [step.name]: result } : {},
|
|
209
|
-
}
|
|
210
|
-
} catch (err) {
|
|
211
|
-
// Persist the route decision so a retry takes the same branch.
|
|
212
|
-
return failureOutcome(step, ctx.attempt, err, writes)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ── loop ────────────────────────────────────────────────────────────────────
|
|
217
|
-
|
|
218
|
-
async function runLoop(
|
|
219
|
-
step: Extract<DurableStep, { type: 'loop' }>,
|
|
220
|
-
ctx: DurableContext,
|
|
221
|
-
journal: Map<string, JournalRecord>
|
|
222
|
-
): Promise<StepOutcome> {
|
|
223
|
-
let currentInput: unknown = step.mapInput ? step.mapInput(ctx) : ctx.input
|
|
224
|
-
let lastResult: unknown
|
|
225
|
-
let ran = false
|
|
226
|
-
const writes: JournalWrite[] = []
|
|
227
|
-
|
|
228
|
-
for (let i = 0; i < step.maxIterations; i++) {
|
|
229
|
-
ran = true
|
|
230
|
-
const jid = `${step.name}#iter${i}`
|
|
231
|
-
const existing = journal.get(jid)
|
|
232
|
-
|
|
233
|
-
if (existing?.status === 'completed') {
|
|
234
|
-
lastResult = existing.result
|
|
235
|
-
} else {
|
|
236
|
-
try {
|
|
237
|
-
lastResult = await step.handler(currentInput, ctx)
|
|
238
|
-
} catch (err) {
|
|
239
|
-
return failureOutcome(step, ctx.attempt, err, writes)
|
|
240
|
-
}
|
|
241
|
-
writes.push({ stepId: jid, status: 'completed', result: lastResult, attempt: ctx.attempt })
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (step.until?.(lastResult, i + 1)) break
|
|
245
|
-
if (step.feedback) currentInput = step.feedback(lastResult)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
writes.push({ stepId: step.name, status: 'completed', result: lastResult ?? null, attempt: ctx.attempt })
|
|
249
|
-
return {
|
|
250
|
-
kind: 'advance',
|
|
251
|
-
journal: writes,
|
|
252
|
-
resultPatch: ran ? { [step.name]: lastResult } : {},
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ── sleep ───────────────────────────────────────────────────────────────────
|
|
257
|
-
|
|
258
|
-
function runSleep(step: Extract<DurableStep, { type: 'sleep' }>): StepOutcome {
|
|
259
|
-
const wakeAt =
|
|
260
|
-
step.duration instanceof Date
|
|
261
|
-
? step.duration
|
|
262
|
-
: new Date(Date.now() + step.duration)
|
|
263
|
-
return {
|
|
264
|
-
kind: 'sleep',
|
|
265
|
-
journal: [
|
|
266
|
-
{
|
|
267
|
-
stepId: step.name,
|
|
268
|
-
status: 'completed',
|
|
269
|
-
result: { wakeAt: wakeAt.toISOString() },
|
|
270
|
-
attempt: 1,
|
|
271
|
-
},
|
|
272
|
-
],
|
|
273
|
-
resultPatch: {},
|
|
274
|
-
wakeAt,
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ── child ───────────────────────────────────────────────────────────────────
|
|
279
|
-
|
|
280
|
-
function runChild(
|
|
281
|
-
step: Extract<DurableStep, { type: 'child' }>,
|
|
282
|
-
ctx: DurableContext
|
|
283
|
-
): StepOutcome {
|
|
284
|
-
const childInput = step.mapInput ? step.mapInput(ctx) : ctx.input
|
|
285
|
-
return {
|
|
286
|
-
kind: 'await-child',
|
|
287
|
-
childName: step.childName,
|
|
288
|
-
childInput,
|
|
289
|
-
childStepId: step.name,
|
|
290
|
-
}
|
|
291
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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/models/index.ts
DELETED
package/src/models/journal.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,36 +0,0 @@
|
|
|
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
|
-
}
|