@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/errors/error-handler.d.ts +8 -0
  3. package/dist/errors/error-handler.js +8 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.js +1 -0
  6. package/dist/services/in-memory-queue-service.d.ts +9 -0
  7. package/dist/services/in-memory-queue-service.js +42 -11
  8. package/dist/services/in-memory-workflow-service.js +2 -0
  9. package/dist/services/workflow-service.d.ts +3 -0
  10. package/dist/testing/service-tests.js +35 -0
  11. package/dist/types/core.types.d.ts +1 -0
  12. package/dist/wirings/cli/cli-runner.js +12 -3
  13. package/dist/wirings/workflow/graph/graph-runner.js +51 -61
  14. package/dist/wirings/workflow/index.d.ts +2 -0
  15. package/dist/wirings/workflow/index.js +2 -0
  16. package/dist/wirings/workflow/pikku-workflow-service.d.ts +32 -0
  17. package/dist/wirings/workflow/pikku-workflow-service.js +136 -119
  18. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  19. package/dist/wirings/workflow/run-timeline.js +153 -0
  20. package/dist/wirings/workflow/workflow.types.d.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/errors/error-handler.ts +10 -0
  23. package/src/index.ts +1 -0
  24. package/src/services/in-memory-queue-service.test.ts +97 -0
  25. package/src/services/in-memory-queue-service.ts +51 -16
  26. package/src/services/in-memory-workflow-service.ts +2 -0
  27. package/src/services/workflow-service.ts +9 -0
  28. package/src/testing/service-tests.ts +65 -0
  29. package/src/types/core.types.ts +4 -0
  30. package/src/wirings/cli/cli-runner.ts +11 -3
  31. package/src/wirings/workflow/graph/graph-runner.test.ts +72 -0
  32. package/src/wirings/workflow/graph/graph-runner.ts +95 -86
  33. package/src/wirings/workflow/index.ts +14 -0
  34. package/src/wirings/workflow/pikku-workflow-service.ts +193 -137
  35. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  36. package/src/wirings/workflow/run-timeline.ts +241 -0
  37. package/src/wirings/workflow/workflow.types.ts +2 -0
  38. 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 }