@pikku/core 0.12.37 → 0.12.39
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 +113 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +27 -1
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +7 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildRunTimeline,
|
|
6
|
+
reconstructStateAt,
|
|
7
|
+
reconstructFinalState,
|
|
8
|
+
} from './run-timeline.js'
|
|
9
|
+
import type { StepState } from './workflow.types.js'
|
|
10
|
+
|
|
11
|
+
type HistoryEntry = StepState & { stepName: string }
|
|
12
|
+
|
|
13
|
+
const T = (ms: number) => new Date(1_700_000_000_000 + ms)
|
|
14
|
+
|
|
15
|
+
/** A succeeded step attempt with the standard lifecycle timestamps. */
|
|
16
|
+
const ok = (
|
|
17
|
+
stepName: string,
|
|
18
|
+
base: number,
|
|
19
|
+
opts: { from?: string; result?: unknown; attempt?: number } = {}
|
|
20
|
+
): HistoryEntry =>
|
|
21
|
+
({
|
|
22
|
+
stepId: `${stepName}-${opts.attempt ?? 1}`,
|
|
23
|
+
stepName,
|
|
24
|
+
status: 'succeeded',
|
|
25
|
+
attemptCount: opts.attempt ?? 1,
|
|
26
|
+
fromStepName: opts.from,
|
|
27
|
+
result: opts.result,
|
|
28
|
+
createdAt: T(base),
|
|
29
|
+
runningAt: T(base + 1),
|
|
30
|
+
succeededAt: T(base + 2),
|
|
31
|
+
updatedAt: T(base + 2),
|
|
32
|
+
}) as HistoryEntry
|
|
33
|
+
|
|
34
|
+
const failed = (
|
|
35
|
+
stepName: string,
|
|
36
|
+
base: number,
|
|
37
|
+
opts: { from?: string; attempt?: number; message?: string } = {}
|
|
38
|
+
): HistoryEntry =>
|
|
39
|
+
({
|
|
40
|
+
stepId: `${stepName}-${opts.attempt ?? 1}`,
|
|
41
|
+
stepName,
|
|
42
|
+
status: 'failed',
|
|
43
|
+
attemptCount: opts.attempt ?? 1,
|
|
44
|
+
fromStepName: opts.from,
|
|
45
|
+
error: { message: opts.message ?? 'boom' },
|
|
46
|
+
createdAt: T(base),
|
|
47
|
+
runningAt: T(base + 1),
|
|
48
|
+
failedAt: T(base + 2),
|
|
49
|
+
updatedAt: T(base + 2),
|
|
50
|
+
}) as HistoryEntry
|
|
51
|
+
|
|
52
|
+
describe('buildRunTimeline', () => {
|
|
53
|
+
test('explodes each attempt into ordered lifecycle events', () => {
|
|
54
|
+
const tl = buildRunTimeline([ok('begin', 0, { result: { n: 1 } })])
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
tl.map((e) => e.type),
|
|
57
|
+
['pending', 'running', 'succeeded']
|
|
58
|
+
)
|
|
59
|
+
// seq is monotonic from 0
|
|
60
|
+
assert.deepEqual(
|
|
61
|
+
tl.map((e) => e.seq),
|
|
62
|
+
[0, 1, 2]
|
|
63
|
+
)
|
|
64
|
+
// result rides only on the succeeded event
|
|
65
|
+
assert.deepEqual(tl[2]!.result, { n: 1 })
|
|
66
|
+
assert.equal(tl[0]!.result, undefined)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('orders across steps by timestamp, lifecycle, then history index', () => {
|
|
70
|
+
// two steps; b created at the same instant begin succeeds
|
|
71
|
+
const tl = buildRunTimeline([
|
|
72
|
+
ok('begin', 0, { result: 1 }),
|
|
73
|
+
ok('next', 2, { from: 'begin', result: 2 }),
|
|
74
|
+
])
|
|
75
|
+
// begin.pending(0) begin.running(1) begin.succeeded(2)==next.pending(2)
|
|
76
|
+
// → succeeded sorts before pending at the same instant (lifecycle 3 vs 0)?
|
|
77
|
+
// No: pending=0 < succeeded=3, so next.pending precedes begin.succeeded.
|
|
78
|
+
const at2 = tl.filter((e) => e.at.getTime() === T(2).getTime())
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
at2.map((e) => `${e.stepName}:${e.type}`),
|
|
81
|
+
['next:pending', 'begin:succeeded']
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('carries provenance on the created event only', () => {
|
|
86
|
+
const tl = buildRunTimeline([ok('next', 5, { from: 'begin' })])
|
|
87
|
+
const created = tl.find((e) => e.type === 'pending')!
|
|
88
|
+
assert.equal(created.fromStepName, 'begin')
|
|
89
|
+
assert.equal(
|
|
90
|
+
tl.find((e) => e.type === 'succeeded')!.fromStepName,
|
|
91
|
+
undefined
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('terminal event is driven by status, not lifecycle timestamps', () => {
|
|
96
|
+
// Kysely leaves succeededAt/runningAt null but the row's status + result
|
|
97
|
+
// are authoritative — the terminal event must still fire (off updatedAt).
|
|
98
|
+
const noStamps = {
|
|
99
|
+
stepId: 's-1',
|
|
100
|
+
stepName: 'begin',
|
|
101
|
+
status: 'succeeded',
|
|
102
|
+
attemptCount: 1,
|
|
103
|
+
result: { ok: 1 },
|
|
104
|
+
createdAt: T(0),
|
|
105
|
+
updatedAt: T(5),
|
|
106
|
+
} as HistoryEntry
|
|
107
|
+
const tl = buildRunTimeline([noStamps])
|
|
108
|
+
assert.deepEqual(
|
|
109
|
+
tl.map((e) => e.type),
|
|
110
|
+
['pending', 'succeeded']
|
|
111
|
+
)
|
|
112
|
+
const succeeded = tl.find((e) => e.type === 'succeeded')!
|
|
113
|
+
assert.equal(succeeded.at.getTime(), T(5).getTime(), 'falls back to updatedAt')
|
|
114
|
+
assert.deepEqual(succeeded.result, { ok: 1 })
|
|
115
|
+
assert.deepEqual(reconstructFinalState(tl).results, { begin: { ok: 1 } })
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('reconstructStateAt', () => {
|
|
120
|
+
// begin → next → finish, each succeeding in order
|
|
121
|
+
const history: HistoryEntry[] = [
|
|
122
|
+
ok('begin', 0, { result: { step: 'begin' } }),
|
|
123
|
+
ok('next', 10, { from: 'begin', result: { step: 'next' } }),
|
|
124
|
+
ok('finish', 20, { from: 'next', result: { step: 'finish' } }),
|
|
125
|
+
]
|
|
126
|
+
const tl = buildRunTimeline(history)
|
|
127
|
+
|
|
128
|
+
test('a point before the first event is the empty initial state', () => {
|
|
129
|
+
const s = reconstructStateAt(tl, -1)
|
|
130
|
+
assert.equal(s.seq, -1)
|
|
131
|
+
assert.deepEqual(s.steps, [])
|
|
132
|
+
assert.deepEqual(s.results, {})
|
|
133
|
+
assert.equal(s.phase, 'pending')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('mid-run by seq: only steps up to that event exist', () => {
|
|
137
|
+
// fold through begin.succeeded (seq 2)
|
|
138
|
+
const s = reconstructStateAt(tl, 2)
|
|
139
|
+
assert.deepEqual(s.path, ['begin'])
|
|
140
|
+
assert.equal(s.steps[0]!.status, 'succeeded')
|
|
141
|
+
assert.deepEqual(s.results, { begin: { step: 'begin' } })
|
|
142
|
+
// next hasn't been created yet at this point
|
|
143
|
+
assert.equal(s.phase, 'idle')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('mid-run captures an in-flight step as running', () => {
|
|
147
|
+
// begin done (0,1,2), next created+running (3,4) but not yet succeeded
|
|
148
|
+
const s = reconstructStateAt(tl, 4)
|
|
149
|
+
assert.deepEqual(s.path, ['begin', 'next'])
|
|
150
|
+
assert.equal(s.steps[1]!.stepName, 'next')
|
|
151
|
+
assert.equal(s.steps[1]!.status, 'running')
|
|
152
|
+
assert.equal(s.steps[1]!.fromStepName, 'begin')
|
|
153
|
+
assert.equal(s.phase, 'running')
|
|
154
|
+
// next's result not yet available in the replay cache
|
|
155
|
+
assert.deepEqual(s.results, { begin: { step: 'begin' } })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('time-travel by Date folds every event at or before the instant', () => {
|
|
159
|
+
const s = reconstructStateAt(tl, T(12))
|
|
160
|
+
// begin fully done (<=2), next created(10)+running(11) but succeeded at 12
|
|
161
|
+
assert.deepEqual(s.path, ['begin', 'next'])
|
|
162
|
+
assert.equal(s.steps[1]!.status, 'succeeded')
|
|
163
|
+
assert.deepEqual(s.results, {
|
|
164
|
+
begin: { step: 'begin' },
|
|
165
|
+
next: { step: 'next' },
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('final state has the full walked path and all results', () => {
|
|
170
|
+
const s = reconstructFinalState(tl)
|
|
171
|
+
assert.deepEqual(s.path, ['begin', 'next', 'finish'])
|
|
172
|
+
assert.deepEqual(s.results, {
|
|
173
|
+
begin: { step: 'begin' },
|
|
174
|
+
next: { step: 'next' },
|
|
175
|
+
finish: { step: 'finish' },
|
|
176
|
+
})
|
|
177
|
+
assert.equal(s.phase, 'idle')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('reconstructStateAt — retries', () => {
|
|
182
|
+
// attempt 1 fails, attempt 2 (created later) succeeds
|
|
183
|
+
const history: HistoryEntry[] = [
|
|
184
|
+
failed('flaky', 0, { message: 'first try' }),
|
|
185
|
+
ok('flaky', 10, { result: { ok: true }, attempt: 2 }),
|
|
186
|
+
]
|
|
187
|
+
const tl = buildRunTimeline(history)
|
|
188
|
+
|
|
189
|
+
test('between attempts the step is failed', () => {
|
|
190
|
+
const s = reconstructStateAt(tl, T(2)) // through failedAt of attempt 1
|
|
191
|
+
assert.equal(s.steps[0]!.status, 'failed')
|
|
192
|
+
assert.equal(s.steps[0]!.error?.message, 'first try')
|
|
193
|
+
assert.equal(s.phase, 'failed')
|
|
194
|
+
assert.deepEqual(s.results, {})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("the retry's created event reopens the step and clears the error", () => {
|
|
198
|
+
const s = reconstructStateAt(tl, T(10)) // attempt-2 pending
|
|
199
|
+
assert.equal(s.steps[0]!.status, 'pending')
|
|
200
|
+
assert.equal(s.steps[0]!.error, undefined)
|
|
201
|
+
assert.equal(s.steps[0]!.attemptCount, 2)
|
|
202
|
+
assert.equal(s.phase, 'running')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('after the retry succeeds the result is available', () => {
|
|
206
|
+
const s = reconstructFinalState(tl)
|
|
207
|
+
assert.equal(s.steps[0]!.status, 'succeeded')
|
|
208
|
+
assert.deepEqual(s.results, { flaky: { ok: true } })
|
|
209
|
+
assert.equal(s.path.length, 1, 'a retried step is still one path entry')
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { SerializedError } from '../../types/core.types.js'
|
|
2
|
+
import type { StepState, StepStatus } from './workflow.types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Time-travel over a workflow run.
|
|
6
|
+
*
|
|
7
|
+
* A run's durable history (`getRunHistory`) is one row per step *attempt*, each
|
|
8
|
+
* carrying lifecycle timestamps (created/scheduled/running/succeeded/failed).
|
|
9
|
+
* `buildRunTimeline` explodes those rows into a flat, chronologically-ordered
|
|
10
|
+
* event stream, and `reconstructStateAt` folds the stream up to any point to
|
|
11
|
+
* recover what the run "knew" then — the same step cache a replay would hold:
|
|
12
|
+
* per-step status, accumulated results, and the walked path.
|
|
13
|
+
*
|
|
14
|
+
* These are pure functions over history — no IO — so they're trivially testable
|
|
15
|
+
* and transport-independent (the same fold works for Redis/Kysely/in-memory).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A single observable transition of one step attempt. */
|
|
19
|
+
export interface RunTimelineEvent {
|
|
20
|
+
/** Monotonic 0-based position in the timeline. */
|
|
21
|
+
seq: number
|
|
22
|
+
/** When it happened. */
|
|
23
|
+
at: Date
|
|
24
|
+
/** The step's new status at this event (matches StepStatus lifecycle). */
|
|
25
|
+
type: Extract<
|
|
26
|
+
StepStatus,
|
|
27
|
+
'pending' | 'scheduled' | 'running' | 'succeeded' | 'failed'
|
|
28
|
+
>
|
|
29
|
+
/** Physical step name (includes revisit ordinal, e.g. `attempt#2`). */
|
|
30
|
+
stepName: string
|
|
31
|
+
/** Which attempt this event belongs to (1-based). */
|
|
32
|
+
attemptCount: number
|
|
33
|
+
/** Predecessor that scheduled this step — only on the `pending` (created)
|
|
34
|
+
* event; undefined for entry steps. Reconstructs the walked edge. */
|
|
35
|
+
fromStepName?: string
|
|
36
|
+
/** Result snapshot — only on `succeeded`. */
|
|
37
|
+
result?: unknown
|
|
38
|
+
/** Error snapshot — only on `failed`. */
|
|
39
|
+
error?: SerializedError
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type RunTimeline = RunTimelineEvent[]
|
|
43
|
+
|
|
44
|
+
type HistoryEntry = StepState & { stepName: string }
|
|
45
|
+
|
|
46
|
+
/** Lifecycle tiebreak for events that share a timestamp. */
|
|
47
|
+
const LIFECYCLE_ORDER: Record<RunTimelineEvent['type'], number> = {
|
|
48
|
+
pending: 0,
|
|
49
|
+
scheduled: 1,
|
|
50
|
+
running: 2,
|
|
51
|
+
succeeded: 3,
|
|
52
|
+
failed: 3,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the ordered event stream for a run from its raw history.
|
|
57
|
+
*
|
|
58
|
+
* History is expected oldest-first but is sorted defensively by (timestamp,
|
|
59
|
+
* lifecycle, original index) so simultaneous timestamps stay deterministic.
|
|
60
|
+
*/
|
|
61
|
+
export function buildRunTimeline(history: HistoryEntry[]): RunTimeline {
|
|
62
|
+
const raw: Array<Omit<RunTimelineEvent, 'seq'> & { order: number }> = []
|
|
63
|
+
|
|
64
|
+
history.forEach((entry, order) => {
|
|
65
|
+
const base = {
|
|
66
|
+
stepName: entry.stepName,
|
|
67
|
+
attemptCount: entry.attemptCount,
|
|
68
|
+
order,
|
|
69
|
+
}
|
|
70
|
+
// The created event always exists; it carries provenance.
|
|
71
|
+
raw.push({
|
|
72
|
+
...base,
|
|
73
|
+
at: entry.createdAt,
|
|
74
|
+
type: 'pending',
|
|
75
|
+
fromStepName: entry.fromStepName,
|
|
76
|
+
})
|
|
77
|
+
// Intermediate events are optional enrichment — emit only if the backend
|
|
78
|
+
// recorded their timestamp.
|
|
79
|
+
if (entry.scheduledAt) {
|
|
80
|
+
raw.push({ ...base, at: entry.scheduledAt, type: 'scheduled' })
|
|
81
|
+
}
|
|
82
|
+
if (entry.runningAt) {
|
|
83
|
+
raw.push({ ...base, at: entry.runningAt, type: 'running' })
|
|
84
|
+
}
|
|
85
|
+
// The terminal event is driven by the row's authoritative status (the
|
|
86
|
+
// lifecycle timestamps aren't populated by every backend — Kysely leaves
|
|
87
|
+
// them null), falling back to updatedAt when the specific stamp is absent.
|
|
88
|
+
if (entry.status === 'succeeded') {
|
|
89
|
+
raw.push({
|
|
90
|
+
...base,
|
|
91
|
+
at: entry.succeededAt ?? entry.updatedAt,
|
|
92
|
+
type: 'succeeded',
|
|
93
|
+
result: entry.result,
|
|
94
|
+
})
|
|
95
|
+
} else if (entry.status === 'failed') {
|
|
96
|
+
raw.push({
|
|
97
|
+
...base,
|
|
98
|
+
at: entry.failedAt ?? entry.updatedAt,
|
|
99
|
+
type: 'failed',
|
|
100
|
+
error: entry.error,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
raw.sort((a, b) => {
|
|
106
|
+
const ta = a.at.getTime()
|
|
107
|
+
const tb = b.at.getTime()
|
|
108
|
+
if (ta !== tb) return ta - tb
|
|
109
|
+
const la = LIFECYCLE_ORDER[a.type]
|
|
110
|
+
const lb = LIFECYCLE_ORDER[b.type]
|
|
111
|
+
if (la !== lb) return la - lb
|
|
112
|
+
return a.order - b.order
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return raw.map(({ order: _order, ...event }, seq) => ({ ...event, seq }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A step's reconstructed state at a point in the timeline. */
|
|
119
|
+
export interface ReconstructedStep {
|
|
120
|
+
stepName: string
|
|
121
|
+
status: StepStatus
|
|
122
|
+
attemptCount: number
|
|
123
|
+
fromStepName?: string
|
|
124
|
+
result?: unknown
|
|
125
|
+
error?: SerializedError
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Coarse run phase derived purely from step states (not the run's output). */
|
|
129
|
+
export type RunPhase = 'pending' | 'running' | 'failed' | 'idle'
|
|
130
|
+
|
|
131
|
+
/** The whole run's reconstructed state at a point in the timeline. */
|
|
132
|
+
export interface ReconstructedRunState {
|
|
133
|
+
/** seq of the last applied event, or -1 if the point precedes all events. */
|
|
134
|
+
seq: number
|
|
135
|
+
/** Wall-clock time of the last applied event. */
|
|
136
|
+
at?: Date
|
|
137
|
+
/** Per-step latest state, in first-seen (walked) order. */
|
|
138
|
+
steps: ReconstructedStep[]
|
|
139
|
+
/** Outputs available to downstream steps — the replay step cache at this
|
|
140
|
+
* point (succeeded steps only). */
|
|
141
|
+
results: Record<string, unknown>
|
|
142
|
+
/** Step names in the order they were first created — the walked path. */
|
|
143
|
+
path: string[]
|
|
144
|
+
/** Derived phase: `running` if any step is in-flight, else `failed` if a step
|
|
145
|
+
* is failed with no in-flight work, else `idle` (between transitions). */
|
|
146
|
+
phase: RunPhase
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const IN_FLIGHT: ReadonlySet<StepStatus> = new Set([
|
|
150
|
+
'pending',
|
|
151
|
+
'scheduled',
|
|
152
|
+
'running',
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fold the timeline up to `at` and return the run's state at that point.
|
|
157
|
+
*
|
|
158
|
+
* `at` is either a seq index (inclusive — apply events `0..at`) or a `Date`
|
|
159
|
+
* (inclusive — apply every event at or before that instant). A point before the
|
|
160
|
+
* first event yields the empty initial state.
|
|
161
|
+
*/
|
|
162
|
+
export function reconstructStateAt(
|
|
163
|
+
timeline: RunTimeline,
|
|
164
|
+
at: number | Date
|
|
165
|
+
): ReconstructedRunState {
|
|
166
|
+
const cutoff = (event: RunTimelineEvent): boolean =>
|
|
167
|
+
typeof at === 'number'
|
|
168
|
+
? event.seq <= at
|
|
169
|
+
: event.at.getTime() <= at.getTime()
|
|
170
|
+
|
|
171
|
+
const steps = new Map<string, ReconstructedStep>()
|
|
172
|
+
const path: string[] = []
|
|
173
|
+
let lastSeq = -1
|
|
174
|
+
let lastAt: Date | undefined
|
|
175
|
+
|
|
176
|
+
for (const event of timeline) {
|
|
177
|
+
if (!cutoff(event)) break
|
|
178
|
+
lastSeq = event.seq
|
|
179
|
+
lastAt = event.at
|
|
180
|
+
|
|
181
|
+
let step = steps.get(event.stepName)
|
|
182
|
+
if (!step) {
|
|
183
|
+
step = {
|
|
184
|
+
stepName: event.stepName,
|
|
185
|
+
status: event.type,
|
|
186
|
+
attemptCount: event.attemptCount,
|
|
187
|
+
fromStepName: event.fromStepName,
|
|
188
|
+
}
|
|
189
|
+
steps.set(event.stepName, step)
|
|
190
|
+
path.push(event.stepName)
|
|
191
|
+
}
|
|
192
|
+
step.status = event.type
|
|
193
|
+
step.attemptCount = event.attemptCount
|
|
194
|
+
if (event.fromStepName !== undefined) {
|
|
195
|
+
step.fromStepName = event.fromStepName
|
|
196
|
+
}
|
|
197
|
+
// A retry's created event reopens the step — drop the prior outcome.
|
|
198
|
+
if (event.type === 'pending') {
|
|
199
|
+
delete step.result
|
|
200
|
+
delete step.error
|
|
201
|
+
}
|
|
202
|
+
if (event.type === 'succeeded') {
|
|
203
|
+
step.result = event.result
|
|
204
|
+
step.error = undefined
|
|
205
|
+
}
|
|
206
|
+
if (event.type === 'failed') {
|
|
207
|
+
step.error = event.error
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const orderedSteps = path.map((name) => steps.get(name)!)
|
|
212
|
+
const results: Record<string, unknown> = {}
|
|
213
|
+
for (const step of orderedSteps) {
|
|
214
|
+
if (step.status === 'succeeded') {
|
|
215
|
+
results[step.stepName] = step.result
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
seq: lastSeq,
|
|
221
|
+
at: lastAt,
|
|
222
|
+
steps: orderedSteps,
|
|
223
|
+
results,
|
|
224
|
+
path,
|
|
225
|
+
phase: derivePhase(orderedSteps),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reconstruct the final state (fold the whole timeline). */
|
|
230
|
+
export function reconstructFinalState(
|
|
231
|
+
timeline: RunTimeline
|
|
232
|
+
): ReconstructedRunState {
|
|
233
|
+
return reconstructStateAt(timeline, timeline.length - 1)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function derivePhase(steps: ReconstructedStep[]): RunPhase {
|
|
237
|
+
if (steps.length === 0) return 'pending'
|
|
238
|
+
if (steps.some((s) => IN_FLIGHT.has(s.status))) return 'running'
|
|
239
|
+
if (steps.some((s) => s.status === 'failed')) return 'failed'
|
|
240
|
+
return 'idle'
|
|
241
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
import { WorkflowDispatchException } from './pikku-workflow-service.js'
|
|
7
|
+
|
|
8
|
+
// Register an `inline: false` function so the workflow's `do()` routes the step
|
|
9
|
+
// through the queue (dispatchStep) instead of running it inline.
|
|
10
|
+
function registerDispatchedFn(rpcName: string): void {
|
|
11
|
+
const funcId = `fn:${rpcName}`
|
|
12
|
+
pikkuState(null, 'rpc', 'meta', { [rpcName]: funcId } as any)
|
|
13
|
+
pikkuState(null, 'function', 'meta', {
|
|
14
|
+
[funcId]: { inline: false },
|
|
15
|
+
} as any)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
19
|
+
|
|
20
|
+
describe('dispatch durability: a transient queue failure is recoverable', () => {
|
|
21
|
+
test('failed dispatch leaves the step PENDING and the run un-failed, then re-dispatches on replay', async () => {
|
|
22
|
+
registerDispatchedFn('doThing')
|
|
23
|
+
let queueUp = false
|
|
24
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
25
|
+
queueService: {
|
|
26
|
+
add: async () => {
|
|
27
|
+
if (!queueUp) throw new Error('pg-boss is down')
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
logger: silentLogger,
|
|
31
|
+
} as any)
|
|
32
|
+
|
|
33
|
+
const ws = new InMemoryWorkflowService()
|
|
34
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
|
|
35
|
+
await ws.insertStepState(runId, 'thing', 'doThing', {})
|
|
36
|
+
|
|
37
|
+
// Queue is down → dispatch throws the TRANSIENT exception (not a step failure).
|
|
38
|
+
await assert.rejects(
|
|
39
|
+
(ws as any).rpcStep(runId, 'thing', 'doThing', {}, {}),
|
|
40
|
+
(err: any) => err instanceof WorkflowDispatchException,
|
|
41
|
+
'a queue outage surfaces as WorkflowDispatchException'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// The step must NOT be stranded in `scheduled` — otherwise replay would pause
|
|
45
|
+
// forever on a job that was never enqueued.
|
|
46
|
+
const afterFail = await ws.getStepState(runId, 'thing')
|
|
47
|
+
assert.equal(
|
|
48
|
+
afterFail.status,
|
|
49
|
+
'pending',
|
|
50
|
+
'step stays pending after a failed dispatch'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// The run itself must stay alive (not failed) — it is the snapshot we replay.
|
|
54
|
+
const runAfterFail = await ws.getRun(runId)
|
|
55
|
+
assert.notEqual(runAfterFail?.status, 'failed', 'run is not failed by a queue blip')
|
|
56
|
+
|
|
57
|
+
// Queue recovers → replaying the step now dispatches (pauses via async exception)
|
|
58
|
+
// and the step transitions to `scheduled`. A real replay runs through
|
|
59
|
+
// runWorkflowJob which resets the ordinal counter; simulate that so the
|
|
60
|
+
// second reach resolves to the same step key, not the next ordinal.
|
|
61
|
+
;(ws as any).resetStepOrdinals(runId)
|
|
62
|
+
queueUp = true
|
|
63
|
+
await assert.rejects(
|
|
64
|
+
(ws as any).rpcStep(runId, 'thing', 'doThing', {}, {}),
|
|
65
|
+
(err: any) => err.name === 'WorkflowAsyncException',
|
|
66
|
+
'recovered dispatch pauses the workflow as normal'
|
|
67
|
+
)
|
|
68
|
+
const afterRecover = await ws.getStepState(runId, 'thing')
|
|
69
|
+
assert.equal(
|
|
70
|
+
afterRecover.status,
|
|
71
|
+
'scheduled',
|
|
72
|
+
'step is scheduled only after a successful dispatch'
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('orchestrateWorkflow treats a dispatch failure as non-terminal', () => {
|
|
78
|
+
function makeService(throwIt: () => never) {
|
|
79
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
80
|
+
queueService: { add: async () => {} },
|
|
81
|
+
logger: silentLogger,
|
|
82
|
+
} as any)
|
|
83
|
+
const ws = new InMemoryWorkflowService()
|
|
84
|
+
// Override the replay to throw whatever the test wants.
|
|
85
|
+
;(ws as any).runWorkflowJob = async () => throwIt()
|
|
86
|
+
return ws
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('WorkflowDispatchException → run NOT marked failed, error rethrown for queue retry', async () => {
|
|
90
|
+
const ws = makeService(() => {
|
|
91
|
+
throw new WorkflowDispatchException('r', 's')
|
|
92
|
+
})
|
|
93
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
ws.orchestrateWorkflow(runId, {}),
|
|
97
|
+
(err: any) => err instanceof WorkflowDispatchException
|
|
98
|
+
)
|
|
99
|
+
const run = await ws.getRun(runId)
|
|
100
|
+
assert.notEqual(
|
|
101
|
+
run?.status,
|
|
102
|
+
'failed',
|
|
103
|
+
'a transient dispatch failure must leave the run resumable'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('a genuine step error still fails the run (unchanged behavior)', async () => {
|
|
108
|
+
const ws = makeService(() => {
|
|
109
|
+
throw new Error('business logic blew up')
|
|
110
|
+
})
|
|
111
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
|
|
112
|
+
|
|
113
|
+
await assert.rejects(ws.orchestrateWorkflow(runId, {}))
|
|
114
|
+
const run = await ws.getRun(runId)
|
|
115
|
+
assert.equal(run?.status, 'failed', 'real errors still fail the run')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { uuidv5, deriveInvocationId } from './workflow-invocation-id.js'
|
|
5
|
+
|
|
6
|
+
const UUID_RE =
|
|
7
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
8
|
+
|
|
9
|
+
describe('uuidv5', () => {
|
|
10
|
+
test('matches the canonical RFC 4122 v5 vector', () => {
|
|
11
|
+
// www.example.com in the DNS namespace → known fixed UUID. Proves the
|
|
12
|
+
// SHA-1 + version/variant bit-twiddling is a correct v5 implementation.
|
|
13
|
+
const DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
|
14
|
+
assert.equal(
|
|
15
|
+
uuidv5('www.example.com', DNS),
|
|
16
|
+
'2ed6657d-e927-568b-95e1-2665a8aea6a2'
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('is deterministic and sets version 5 + RFC variant bits', () => {
|
|
21
|
+
const a = uuidv5('hello')
|
|
22
|
+
const b = uuidv5('hello')
|
|
23
|
+
assert.equal(a, b, 'same input → same UUID')
|
|
24
|
+
assert.match(a, UUID_RE, 'version nibble is 5 and variant is 8/9/a/b')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('different names produce different UUIDs', () => {
|
|
28
|
+
assert.notEqual(uuidv5('a'), uuidv5('b'))
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('deriveInvocationId', () => {
|
|
33
|
+
test('is stable across calls for the same run + step (the dedupe key)', () => {
|
|
34
|
+
const id1 = deriveInvocationId('run-1', 'updateUser')
|
|
35
|
+
const id2 = deriveInvocationId('run-1', 'updateUser')
|
|
36
|
+
assert.equal(id1, id2)
|
|
37
|
+
assert.match(id1, UUID_RE)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('differs per step name within a run', () => {
|
|
41
|
+
assert.notEqual(
|
|
42
|
+
deriveInvocationId('run-1', 'updateUser'),
|
|
43
|
+
deriveInvocationId('run-1', 'chargeCard')
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('differs per run for the same step name', () => {
|
|
48
|
+
assert.notEqual(
|
|
49
|
+
deriveInvocationId('run-1', 'updateUser'),
|
|
50
|
+
deriveInvocationId('run-2', 'updateUser')
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
|
|
3
|
+
// Fixed namespace for pikku workflow step invocation IDs. Frozen — changing it
|
|
4
|
+
// would alter every derived invocationId and break dedupe across a deploy.
|
|
5
|
+
const PIKKU_WORKFLOW_NAMESPACE = '70696b6b-7500-5770-9f6c-6f77000a0001'
|
|
6
|
+
|
|
7
|
+
const parseUuid = (uuid: string): Buffer =>
|
|
8
|
+
Buffer.from(uuid.replace(/-/g, ''), 'hex')
|
|
9
|
+
|
|
10
|
+
const formatUuid = (bytes: Buffer): string => {
|
|
11
|
+
const hex = bytes.toString('hex')
|
|
12
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
|
|
17
|
+
* namespace always yields the same UUID — no `uuid` dependency needed.
|
|
18
|
+
*/
|
|
19
|
+
export const uuidv5 = (
|
|
20
|
+
name: string,
|
|
21
|
+
namespace: string = PIKKU_WORKFLOW_NAMESPACE
|
|
22
|
+
): string => {
|
|
23
|
+
const hash = createHash('sha1')
|
|
24
|
+
.update(parseUuid(namespace))
|
|
25
|
+
.update(name, 'utf8')
|
|
26
|
+
.digest()
|
|
27
|
+
const bytes = hash.subarray(0, 16)
|
|
28
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x50 // version 5
|
|
29
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80 // RFC 4122 variant
|
|
30
|
+
return formatUuid(bytes)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The stable identity of one step *invocation* within a run — the idempotency /
|
|
35
|
+
* dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
|
|
36
|
+
* stays identical across every retry of the same call, because it is derived
|
|
37
|
+
* purely from `runId` + `stepName`, both of which are stable across replays.
|
|
38
|
+
*
|
|
39
|
+
* Same inputs → same UUID, so a step can safely
|
|
40
|
+
* `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
|
|
41
|
+
* and a retry of a half-applied side effect is collapsed onto the first attempt.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: calling the *same* `stepName` more than once in a run is not yet
|
|
44
|
+
* disambiguated here (the store still keys steps by `runId:stepName`); when
|
|
45
|
+
* per-invocation ordinals land, the ordinal becomes the third hash component.
|
|
46
|
+
*/
|
|
47
|
+
export const deriveInvocationId = (runId: string, stepName: string): string =>
|
|
48
|
+
uuidv5(`${runId}:${stepName}`)
|
|
@@ -14,6 +14,8 @@ export interface WorkflowStepInput {
|
|
|
14
14
|
stepName: string
|
|
15
15
|
rpcName: string
|
|
16
16
|
data: unknown
|
|
17
|
+
/** Predecessor step name (the walked transition); undefined for entry steps. */
|
|
18
|
+
fromStepName?: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface PikkuWorkflowOrchestratorInput {
|