@pikku/core 0.12.38 → 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 +53 -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.js +2 -0
- 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/graph/graph-runner.js +51 -61
- package/dist/wirings/workflow/index.d.ts +2 -0
- package/dist/wirings/workflow/index.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +136 -119
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- package/dist/wirings/workflow/workflow.types.d.ts +2 -0
- package/package.json +1 -1
- 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 +2 -0
- 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/workflow/graph/graph-runner.test.ts +72 -0
- package/src/wirings/workflow/graph/graph-runner.ts +95 -86
- package/src/wirings/workflow/index.ts +14 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +193 -137
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- package/src/wirings/workflow/workflow.types.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
}
|
|
@@ -164,6 +164,8 @@ export interface WorkflowRunStatus {
|
|
|
164
164
|
name: string
|
|
165
165
|
status: StepStatus
|
|
166
166
|
duration?: number
|
|
167
|
+
/** Number of attempts for this step (1 = first try; > 1 means it retried). */
|
|
168
|
+
attempts?: number
|
|
167
169
|
}>
|
|
168
170
|
output?: unknown
|
|
169
171
|
error?: { message: string }
|