@pikku/kysely 0.12.16 → 0.12.18

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 CHANGED
@@ -1,3 +1,91 @@
1
+ ## 0.12.18
2
+
3
+ ### Patch Changes
4
+
5
+ - 5c0ff0f: Fix `getRunHistory` dropping step provenance (`fromStepName`). The value was persisted on the step row and used by the graph planner, but `getRunHistory` built its rows from the per-attempt history and never carried `fromStepName` through — so run history (and any timeline reconstructed from it) reported no predecessors. Redis and Kysely `getRunHistory` now return `fromStepName`. Also adds the missing `from_step_name` column (+ backfill) to the Kysely workflow mirror's `workflow_step` schema and persists it on mirror inserts, so a mirror-side history has identical provenance.
6
+ - 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
7
+
8
+ `getRunStatus` now returns `attempts` (the latest attempt count) per step, so
9
+ consumers can show retry counts without a second history query. It already
10
+ computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
11
+ workflow stores only stamped those timestamps on the _insert_ path — the
12
+ `running` / `succeeded` / `failed` status transitions updated the history row's
13
+ status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
14
+ was always undefined. The transitions now stamp the matching timestamp, so step
15
+ duration is populated for kysely- and mongodb-backed runs. (Redis already
16
+ stamped on transition.) A shared service-suite test guards both behaviours.
17
+
18
+ - Updated dependencies [4be205f]
19
+ - Updated dependencies [061c717]
20
+ - Updated dependencies [2c55e13]
21
+ - Updated dependencies [c745c26]
22
+ - Updated dependencies [57900b5]
23
+ - Updated dependencies [72694f6]
24
+ - @pikku/core@0.12.39
25
+
26
+ ## 0.12.17
27
+
28
+ ### Patch Changes
29
+
30
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
31
+
32
+ The workflow — not the queue — now owns step retry policy, and each step
33
+ invocation gets a stable idempotency key.
34
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
35
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
36
+ no backoff) so retries fired instantly and couldn't outlast a transient
37
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
38
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
39
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
40
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
41
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
42
+ step retries and the dispatched `attempts` are resolved together so
43
+ "retries exhausted" and "no more redeliveries" are the same event.
44
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
45
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
46
+ attempt), it is identical across retries, so a step can dedupe on it
47
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
48
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
49
+ so a step's backoff silently never applied on Redis), and `registerQueues`
50
+ throws a clear error when no logger is available (matching queue-pg-boss).
51
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
52
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
53
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
54
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
55
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
56
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
57
+ treats as transient: the run is left running and the orchestrator job is
58
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
59
+ than the whole run being marked `failed`. The orchestrator job now also
60
+ carries its own retry policy, so this holds even when the orchestrator queue
61
+ is configured `retry_limit 0`. A genuine step error still fails the run.
62
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
63
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
64
+ physical key (`name` for the first, `name#N` for repeats), so a literal
65
+ duplicate name no longer clobbers the earlier step's state. The first reach
66
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
67
+ are unchanged. Ordinals are derived deterministically from DSL execution order
68
+ and reset each replay.
69
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
70
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
71
+ persisted on the step row across all stores (in-memory, kysely, redis,
72
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
73
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
74
+ consumers get the stable predecessor key without a second persisted id —
75
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
76
+ minted per row, is not). This makes the walked path reconstructable even when
77
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
78
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
79
+ The graph runner now supports **cycles**: a forward edge into an
80
+ already-started node still collapses to a single run (joins/diamonds are
81
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
82
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
83
+ the graph's responsibility (branch routing must converge); the engine enforces
84
+ no visit cap.
85
+
86
+ - Updated dependencies [92cd5b1]
87
+ - @pikku/core@0.12.38
88
+
1
89
  ## 0.12.16
2
90
 
3
91
  ### Patch Changes
@@ -43,6 +43,7 @@ export interface WorkflowStepTable {
43
43
  branchTaken: string | null;
44
44
  retries: number | null;
45
45
  retryDelay: string | null;
46
+ fromStepName: string | null;
46
47
  createdAt: Generated<Date>;
47
48
  updatedAt: Generated<Date>;
48
49
  }
@@ -61,6 +61,7 @@ export class KyselyWorkflowMirror {
61
61
  .addColumn('branch_taken', 'text')
62
62
  .addColumn('retries', 'integer')
63
63
  .addColumn('retry_delay', 'text')
64
+ .addColumn('from_step_name', 'text')
64
65
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
65
66
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
66
67
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -68,6 +69,14 @@ export class KyselyWorkflowMirror {
68
69
  'step_name',
69
70
  ])
70
71
  .execute();
72
+ // Additive: backfill the column on mirror tables created before from_step_name.
73
+ await this.db.schema
74
+ .alterTable('workflow_step')
75
+ .addColumn('from_step_name', 'text')
76
+ .execute()
77
+ .catch(() => {
78
+ // Column already exists (fresh tables include it above) — nothing to do.
79
+ });
71
80
  await this.db.schema
72
81
  .createTable('workflow_step_history')
73
82
  .ifNotExists()
@@ -144,6 +153,7 @@ export class KyselyWorkflowMirror {
144
153
  status: step.status,
145
154
  retries: step.retries ?? null,
146
155
  retryDelay: step.retryDelay?.toString() ?? null,
156
+ fromStepName: step.fromStepName ?? null,
147
157
  createdAt: now,
148
158
  updatedAt: now,
149
159
  })
@@ -137,6 +137,7 @@ export class KyselyWorkflowRunService {
137
137
  .select([
138
138
  's.workflowStepId',
139
139
  's.stepName',
140
+ 's.fromStepName',
140
141
  's.retries',
141
142
  's.retryDelay',
142
143
  'h.status',
@@ -158,6 +159,7 @@ export class KyselyWorkflowRunService {
158
159
  return {
159
160
  stepId,
160
161
  stepName: row.stepName,
162
+ fromStepName: row.fromStepName ?? undefined,
161
163
  status: row.status,
162
164
  result: parseJson(row.result),
163
165
  error: parseJson(row.error),
@@ -1,5 +1,5 @@
1
1
  import type { SerializedError } from '@pikku/core';
2
- import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
2
+ import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type StepStatus, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
3
3
  import type { Kysely } from 'kysely';
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js';
5
5
  export declare class KyselyWorkflowService extends PikkuWorkflowService {
@@ -17,7 +17,7 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
17
17
  protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: {
18
18
  retries?: number;
19
19
  retryDelay?: string | number;
20
- }): Promise<StepState>;
20
+ }, fromStepName?: string): Promise<StepState>;
21
21
  getStepState(runId: string, stepName: string): Promise<StepState>;
22
22
  getRunHistory(runId: string): Promise<Array<StepState & {
23
23
  stepName: string;
@@ -38,6 +38,11 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
38
38
  branchKeys: Record<string, string>;
39
39
  }>;
40
40
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
41
+ getStepInstances(runId: string): Promise<Array<{
42
+ stepName: string;
43
+ status: StepStatus;
44
+ fromStepName?: string;
45
+ }>>;
41
46
  getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
42
47
  protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
43
48
  protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
@@ -55,6 +55,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
55
55
  .addColumn('branch_taken', 'text')
56
56
  .addColumn('retries', 'integer')
57
57
  .addColumn('retry_delay', 'text')
58
+ .addColumn('from_step_name', 'text')
58
59
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
59
60
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
60
61
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -62,6 +63,14 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
62
63
  'step_name',
63
64
  ])
64
65
  .execute();
66
+ // Additive: backfill the column on tables created before from_step_name.
67
+ await this.db.schema
68
+ .alterTable('workflow_step')
69
+ .addColumn('from_step_name', 'text')
70
+ .execute()
71
+ .catch(() => {
72
+ // Column already exists (fresh tables include it above) — nothing to do.
73
+ });
65
74
  await this.db.schema
66
75
  .createTable('workflow_step_history')
67
76
  .ifNotExists()
@@ -132,7 +141,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
132
141
  .where('workflowRunId', '=', id)
133
142
  .execute();
134
143
  }
135
- async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
144
+ async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
136
145
  const stepId = crypto.randomUUID();
137
146
  const now = new Date();
138
147
  await this.db
@@ -146,6 +155,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
146
155
  status: 'pending',
147
156
  retries: stepOptions?.retries ?? null,
148
157
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
158
+ fromStepName: fromStepName ?? null,
149
159
  createdAt: now,
150
160
  updatedAt: now,
151
161
  })
@@ -159,6 +169,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
159
169
  attemptCount: 1,
160
170
  retries: stepOptions?.retries,
161
171
  retryDelay: stepOptions?.retryDelay?.toString(),
172
+ fromStepName,
162
173
  createdAt: now,
163
174
  updatedAt: now,
164
175
  };
@@ -173,6 +184,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
184
  's.error',
174
185
  's.retries',
175
186
  's.retryDelay',
187
+ 's.fromStepName',
176
188
  's.createdAt',
177
189
  's.updatedAt',
178
190
  ])
@@ -195,6 +207,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
195
207
  attemptCount: Number(row.attemptCount),
196
208
  retries: row.retries != null ? Number(row.retries) : undefined,
197
209
  retryDelay: row.retryDelay ?? undefined,
210
+ fromStepName: row.fromStepName ?? undefined,
198
211
  createdAt: new Date(row.createdAt),
199
212
  updatedAt: new Date(row.updatedAt),
200
213
  };
@@ -218,7 +231,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
218
231
  if (latestHistory) {
219
232
  await this.db
220
233
  .updateTable('workflowStepHistory')
221
- .set({ status: 'running' })
234
+ .set({ status: 'running', runningAt: new Date() })
222
235
  .where('historyId', '=', latestHistory.historyId)
223
236
  .execute();
224
237
  }
@@ -295,7 +308,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
295
308
  if (latestHistory) {
296
309
  await this.db
297
310
  .updateTable('workflowStepHistory')
298
- .set({ status: 'succeeded', result: resultJson })
311
+ .set({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
299
312
  .where('historyId', '=', latestHistory.historyId)
300
313
  .execute();
301
314
  }
@@ -327,7 +340,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
327
340
  if (latestHistory) {
328
341
  await this.db
329
342
  .updateTable('workflowStepHistory')
330
- .set({ status: 'failed', error: errorJson })
343
+ .set({ status: 'failed', error: errorJson, failedAt: new Date() })
331
344
  .where('historyId', '=', latestHistory.historyId)
332
345
  .execute();
333
346
  }
@@ -348,6 +361,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
348
361
  's.error',
349
362
  's.retries',
350
363
  's.retryDelay',
364
+ 's.fromStepName',
351
365
  's.createdAt',
352
366
  's.updatedAt',
353
367
  ])
@@ -366,6 +380,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
366
380
  attemptCount: Number(row.attemptCount),
367
381
  retries: row.retries != null ? Number(row.retries) : undefined,
368
382
  retryDelay: row.retryDelay ?? undefined,
383
+ fromStepName: row.fromStepName ?? undefined,
369
384
  createdAt: new Date(row.createdAt),
370
385
  updatedAt: new Date(row.updatedAt),
371
386
  };
@@ -420,6 +435,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
420
435
  const existingStepNames = new Set(result.map((r) => r.stepName));
421
436
  return nodeIds.filter((id) => !existingStepNames.has(id));
422
437
  }
438
+ async getStepInstances(runId) {
439
+ const rows = await this.db
440
+ .selectFrom('workflowStep')
441
+ .select(['stepName', 'status', 'fromStepName'])
442
+ .where('workflowRunId', '=', runId)
443
+ .execute();
444
+ return rows.map((r) => ({
445
+ stepName: r.stepName,
446
+ status: r.status,
447
+ fromStepName: r.fromStepName ?? undefined,
448
+ }));
449
+ }
423
450
  async getNodeResults(runId, nodeIds) {
424
451
  if (nodeIds.length === 0)
425
452
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.16",
3
+ "version": "0.12.18",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.28"
23
+ "@pikku/core": "^0.12.39"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.29.0"
@@ -51,6 +51,7 @@ export interface WorkflowStepTable {
51
51
  branchTaken: string | null
52
52
  retries: number | null
53
53
  retryDelay: string | null
54
+ fromStepName: string | null
54
55
  createdAt: Generated<Date>
55
56
  updatedAt: Generated<Date>
56
57
  }
@@ -103,6 +103,29 @@ describe('KyselyWorkflowMirror — step lifecycle', () => {
103
103
  assert.equal(history[0]!.status, 'pending')
104
104
  })
105
105
 
106
+ test('getRunHistory surfaces step provenance (fromStepName)', async () => {
107
+ // The mirror persists fromStepName and getRunHistory reads it back, so the
108
+ // run history reconstructs the walked path (entry has none, the next step
109
+ // records its predecessor).
110
+ await seedStep(STEP_ID, 'begin')
111
+ await mirror.insertStepState(RUN_ID, {
112
+ stepId: `${STEP_ID}-2`,
113
+ stepName: 'next',
114
+ rpcName: 'rpc.fn',
115
+ data: { x: 2 },
116
+ status: 'pending',
117
+ attemptCount: 1,
118
+ fromStepName: 'begin',
119
+ createdAt: new Date(),
120
+ updatedAt: new Date(),
121
+ } as any)
122
+
123
+ const history = await runService.getRunHistory(RUN_ID)
124
+ const from = new Map(history.map((h) => [h.stepName, h.fromStepName]))
125
+ assert.equal(from.get('begin'), undefined, 'entry step has no predecessor')
126
+ assert.equal(from.get('next'), 'begin', 'next records its predecessor')
127
+ })
128
+
106
129
  test('running → succeeded updates the in-place history row', async () => {
107
130
  await seedStep()
108
131
  await mirror.setStepRunning(STEP_ID)
@@ -79,6 +79,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
79
79
  .addColumn('branch_taken', 'text')
80
80
  .addColumn('retries', 'integer')
81
81
  .addColumn('retry_delay', 'text')
82
+ .addColumn('from_step_name', 'text')
82
83
  .addColumn('created_at', 'timestamp', (col) =>
83
84
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
85
  )
@@ -91,6 +92,15 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
91
92
  ])
92
93
  .execute()
93
94
 
95
+ // Additive: backfill the column on mirror tables created before from_step_name.
96
+ await this.db.schema
97
+ .alterTable('workflow_step')
98
+ .addColumn('from_step_name', 'text')
99
+ .execute()
100
+ .catch(() => {
101
+ // Column already exists (fresh tables include it above) — nothing to do.
102
+ })
103
+
94
104
  await this.db.schema
95
105
  .createTable('workflow_step_history')
96
106
  .ifNotExists()
@@ -202,6 +212,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
202
212
  status: step.status,
203
213
  retries: step.retries ?? null,
204
214
  retryDelay: step.retryDelay?.toString() ?? null,
215
+ fromStepName: step.fromStepName ?? null,
205
216
  createdAt: now,
206
217
  updatedAt: now,
207
218
  })
@@ -192,6 +192,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
192
192
  .select([
193
193
  's.workflowStepId',
194
194
  's.stepName',
195
+ 's.fromStepName',
195
196
  's.retries',
196
197
  's.retryDelay',
197
198
  'h.status',
@@ -215,6 +216,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
215
216
  return {
216
217
  stepId,
217
218
  stepName: row.stepName,
219
+ fromStepName: row.fromStepName ?? undefined,
218
220
  status: row.status as StepState['status'],
219
221
  result: parseJson(row.result),
220
222
  error: parseJson(row.error),
@@ -5,6 +5,7 @@ import {
5
5
  type WorkflowRun,
6
6
  type WorkflowRunWire,
7
7
  type StepState,
8
+ type StepStatus,
8
9
  type WorkflowStatus,
9
10
  type WorkflowVersionStatus,
10
11
  } from '@pikku/core/workflow'
@@ -79,6 +80,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
79
80
  .addColumn('branch_taken', 'text')
80
81
  .addColumn('retries', 'integer')
81
82
  .addColumn('retry_delay', 'text')
83
+ .addColumn('from_step_name', 'text')
82
84
  .addColumn('created_at', 'timestamp', (col) =>
83
85
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
86
  )
@@ -91,6 +93,15 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
91
93
  ])
92
94
  .execute()
93
95
 
96
+ // Additive: backfill the column on tables created before from_step_name.
97
+ await this.db.schema
98
+ .alterTable('workflow_step')
99
+ .addColumn('from_step_name', 'text')
100
+ .execute()
101
+ .catch(() => {
102
+ // Column already exists (fresh tables include it above) — nothing to do.
103
+ })
104
+
94
105
  await this.db.schema
95
106
  .createTable('workflow_step_history')
96
107
  .ifNotExists()
@@ -196,7 +207,8 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
196
207
  stepName: string,
197
208
  rpcName: string | null,
198
209
  data: any,
199
- stepOptions?: { retries?: number; retryDelay?: string | number }
210
+ stepOptions?: { retries?: number; retryDelay?: string | number },
211
+ fromStepName?: string
200
212
  ): Promise<StepState> {
201
213
  const stepId = crypto.randomUUID()
202
214
  const now = new Date()
@@ -212,6 +224,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
212
224
  status: 'pending',
213
225
  retries: stepOptions?.retries ?? null,
214
226
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
227
+ fromStepName: fromStepName ?? null,
215
228
  createdAt: now,
216
229
  updatedAt: now,
217
230
  })
@@ -227,6 +240,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
227
240
  attemptCount: 1,
228
241
  retries: stepOptions?.retries,
229
242
  retryDelay: stepOptions?.retryDelay?.toString(),
243
+ fromStepName,
230
244
  createdAt: now,
231
245
  updatedAt: now,
232
246
  }
@@ -242,6 +256,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
242
256
  's.error',
243
257
  's.retries',
244
258
  's.retryDelay',
259
+ 's.fromStepName',
245
260
  's.createdAt',
246
261
  's.updatedAt',
247
262
  ])
@@ -274,6 +289,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
274
289
  attemptCount: Number(row.attemptCount),
275
290
  retries: row.retries != null ? Number(row.retries) : undefined,
276
291
  retryDelay: row.retryDelay ?? undefined,
292
+ fromStepName: row.fromStepName ?? undefined,
277
293
  createdAt: new Date(row.createdAt),
278
294
  updatedAt: new Date(row.updatedAt),
279
295
  }
@@ -303,7 +319,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
303
319
  if (latestHistory) {
304
320
  await this.db
305
321
  .updateTable('workflowStepHistory')
306
- .set({ status: 'running' })
322
+ .set({ status: 'running', runningAt: new Date() })
307
323
  .where('historyId', '=', latestHistory.historyId)
308
324
  .execute()
309
325
  }
@@ -401,7 +417,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
401
417
  if (latestHistory) {
402
418
  await this.db
403
419
  .updateTable('workflowStepHistory')
404
- .set({ status: 'succeeded', result: resultJson })
420
+ .set({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
405
421
  .where('historyId', '=', latestHistory.historyId)
406
422
  .execute()
407
423
  }
@@ -440,7 +456,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
440
456
  if (latestHistory) {
441
457
  await this.db
442
458
  .updateTable('workflowStepHistory')
443
- .set({ status: 'failed', error: errorJson })
459
+ .set({ status: 'failed', error: errorJson, failedAt: new Date() })
444
460
  .where('historyId', '=', latestHistory.historyId)
445
461
  .execute()
446
462
  }
@@ -467,6 +483,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
467
483
  's.error',
468
484
  's.retries',
469
485
  's.retryDelay',
486
+ 's.fromStepName',
470
487
  's.createdAt',
471
488
  's.updatedAt',
472
489
  ])
@@ -492,6 +509,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
492
509
  attemptCount: Number(row.attemptCount),
493
510
  retries: row.retries != null ? Number(row.retries) : undefined,
494
511
  retryDelay: row.retryDelay ?? undefined,
512
+ fromStepName: row.fromStepName ?? undefined,
495
513
  createdAt: new Date(row.createdAt),
496
514
  updatedAt: new Date(row.updatedAt),
497
515
  }
@@ -568,6 +586,21 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
568
586
  return nodeIds.filter((id) => !existingStepNames.has(id))
569
587
  }
570
588
 
589
+ async getStepInstances(runId: string): Promise<
590
+ Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
591
+ > {
592
+ const rows = await this.db
593
+ .selectFrom('workflowStep')
594
+ .select(['stepName', 'status', 'fromStepName'])
595
+ .where('workflowRunId', '=', runId)
596
+ .execute()
597
+ return rows.map((r) => ({
598
+ stepName: r.stepName,
599
+ status: r.status as StepStatus,
600
+ fromStepName: r.fromStepName ?? undefined,
601
+ }))
602
+ }
603
+
571
604
  async getNodeResults(
572
605
  runId: string,
573
606
  nodeIds: string[]