@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +113 -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.d.ts +7 -2
  9. package/dist/services/in-memory-workflow-service.js +19 -1
  10. package/dist/services/workflow-service.d.ts +3 -0
  11. package/dist/testing/service-tests.js +35 -0
  12. package/dist/types/core.types.d.ts +1 -0
  13. package/dist/wirings/cli/cli-runner.js +12 -3
  14. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  15. package/dist/wirings/workflow/graph/graph-runner.js +168 -106
  16. package/dist/wirings/workflow/index.d.ts +4 -1
  17. package/dist/wirings/workflow/index.js +4 -1
  18. package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
  19. package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
  20. package/dist/wirings/workflow/run-timeline.d.ts +85 -0
  21. package/dist/wirings/workflow/run-timeline.js +153 -0
  22. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  23. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  24. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  25. package/dist/wirings/workflow/workflow.types.d.ts +7 -0
  26. package/package.json +2 -1
  27. package/src/dev/hot-reload.test.ts +5 -0
  28. package/src/errors/error-handler.ts +10 -0
  29. package/src/index.ts +1 -0
  30. package/src/services/in-memory-queue-service.test.ts +97 -0
  31. package/src/services/in-memory-queue-service.ts +51 -16
  32. package/src/services/in-memory-workflow-service.ts +27 -1
  33. package/src/services/workflow-service.ts +9 -0
  34. package/src/testing/service-tests.ts +65 -0
  35. package/src/types/core.types.ts +4 -0
  36. package/src/wirings/cli/cli-runner.ts +11 -3
  37. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  38. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  39. package/src/wirings/workflow/graph/graph-runner.test.ts +159 -3
  40. package/src/wirings/workflow/graph/graph-runner.ts +253 -142
  41. package/src/wirings/workflow/index.ts +17 -0
  42. package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
  43. package/src/wirings/workflow/run-timeline.test.ts +211 -0
  44. package/src/wirings/workflow/run-timeline.ts +241 -0
  45. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  46. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  47. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  48. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  49. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  50. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  51. package/src/wirings/workflow/workflow.types.ts +7 -0
  52. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,116 @@
1
+ ## 0.12.39
2
+
3
+ ### Patch Changes
4
+
5
+ - 4be205f: Dedupe DSL step execution: extract a shared `invokeStepRpc` (step RPC + provenance wire, used by both the queue and inline executors) and a shared `runInlineRetryLoop` (the in-process running→result→retry scaffolding, used by inline RPC steps and inline function steps). No behavior change — the inline path stays straight-through O(K); the queue path keeps its suspend/replay model.
6
+ - 061c717: fix(cli): log just the message for expected failures, keep the stack for uncaught errors
7
+
8
+ A deliberate, expected failure — e.g. `pikku all` aborting because a build gate
9
+ (blocking diagnostics) tripped — was dumping a full workflow stack trace, burying
10
+ the one line that matters. Errors are now classified: a `PikkuError` (or any error
11
+ carrying an `expected` marker) prints its message alone, while a genuinely uncaught
12
+ error still prints the full stack so it can be debugged.
13
+ - New `isExpectedError(error)` helper (exported from `@pikku/core`): true for a
14
+ `PikkuError` or an error flagged `expected`.
15
+ - The `expected` flag is threaded through `SerializedError` and the in-memory
16
+ workflow step store so it survives the step-boundary rehydration that strips the
17
+ error's class.
18
+ - The CLI runner's top-level catch, the `CLILogger`, and the workflow runner's
19
+ failure log all honour it.
20
+ - The blocking-diagnostics abort now throws a `PikkuError` subclass so it is
21
+ treated as expected.
22
+
23
+ - 2c55e13: fix(queue): `InMemoryQueueService` redelivers failed jobs up to `options.attempts` with backoff
24
+
25
+ Previously the in-memory queue ran each job once and dropped it on failure, so a
26
+ transiently-failing workflow step dispatched via `inline: false` would stall the
27
+ run forever (the orchestrator was never resumed). It now honors the `attempts`
28
+ and `backoff` already produced by the workflow step job options, redelivering on
29
+ failure — matching pg-boss/bullmq semantics so local/dev runs recover from
30
+ transient step failures exactly as production does.
31
+
32
+ - c745c26: fix(workflow): inline graph runs use the same transition planner as the queue
33
+
34
+ `continueGraphInline` had its own, weaker graph traversal that couldn't revisit a
35
+ node (no cycles) and never recorded `fromStepName`, so an inline-run graph stored
36
+ different step state than the same graph run through a queue. It now uses the
37
+ shared `planGraphTransitions` planner — inline graphs get joins, cycle revisits
38
+ (`node`, `node#1`, …) and step provenance identical to the queued path, and the
39
+ duplicate traversal logic is removed.
40
+
41
+ - 57900b5: Add workflow run time-travel. A run's durable history (`getRunHistory`) is one row per step attempt with lifecycle timestamps; `buildRunTimeline(history)` explodes those into a flat, chronologically-ordered event stream and `reconstructStateAt(timeline, at)` folds it up to any point — a seq index or a `Date` — to recover what the run "knew" then: per-step status, the accumulated step-result cache, the walked path (via `fromStepName`), and a derived phase. These are pure, transport-independent functions (same fold for Redis/Kysely/in-memory), exported from `@pikku/core/workflow` alongside `reconstructFinalState`. `PikkuWorkflowService` gains `getRunTimeline(id)` and `reconstructRunStateAt(id, at?)` that wrap them over a run's history, inherited by every backend. Correctly handles retries (a retry's created event reopens the step and clears the prior outcome) and graph cycles (revisit ordinals are distinct path entries).
42
+ - 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
43
+
44
+ `getRunStatus` now returns `attempts` (the latest attempt count) per step, so
45
+ consumers can show retry counts without a second history query. It already
46
+ computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
47
+ workflow stores only stamped those timestamps on the _insert_ path — the
48
+ `running` / `succeeded` / `failed` status transitions updated the history row's
49
+ status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
50
+ was always undefined. The transitions now stamp the matching timestamp, so step
51
+ duration is populated for kysely- and mongodb-backed runs. (Redis already
52
+ stamped on transition.) A shared service-suite test guards both behaviours.
53
+
54
+ ## 0.12.38
55
+
56
+ ### Patch Changes
57
+
58
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
59
+
60
+ The workflow — not the queue — now owns step retry policy, and each step
61
+ invocation gets a stable idempotency key.
62
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
63
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
64
+ no backoff) so retries fired instantly and couldn't outlast a transient
65
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
66
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
67
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
68
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
69
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
70
+ step retries and the dispatched `attempts` are resolved together so
71
+ "retries exhausted" and "no more redeliveries" are the same event.
72
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
73
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
74
+ attempt), it is identical across retries, so a step can dedupe on it
75
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
76
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
77
+ so a step's backoff silently never applied on Redis), and `registerQueues`
78
+ throws a clear error when no logger is available (matching queue-pg-boss).
79
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
80
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
81
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
82
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
83
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
84
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
85
+ treats as transient: the run is left running and the orchestrator job is
86
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
87
+ than the whole run being marked `failed`. The orchestrator job now also
88
+ carries its own retry policy, so this holds even when the orchestrator queue
89
+ is configured `retry_limit 0`. A genuine step error still fails the run.
90
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
91
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
92
+ physical key (`name` for the first, `name#N` for repeats), so a literal
93
+ duplicate name no longer clobbers the earlier step's state. The first reach
94
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
95
+ are unchanged. Ordinals are derived deterministically from DSL execution order
96
+ and reset each replay.
97
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
98
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
99
+ persisted on the step row across all stores (in-memory, kysely, redis,
100
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
101
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
102
+ consumers get the stable predecessor key without a second persisted id —
103
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
104
+ minted per row, is not). This makes the walked path reconstructable even when
105
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
106
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
107
+ The graph runner now supports **cycles**: a forward edge into an
108
+ already-started node still collapses to a single run (joins/diamonds are
109
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
110
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
111
+ the graph's responsibility (branch routing must converge); the engine enforces
112
+ no visit cap.
113
+
1
114
  ## 0.12.37
2
115
 
3
116
  ### Patch Changes
@@ -9,6 +9,14 @@ export declare class PikkuError extends Error {
9
9
  */
10
10
  constructor(message?: string);
11
11
  }
12
+ /**
13
+ * Whether an error is a deliberate, expected failure rather than an uncaught
14
+ * bug. A `PikkuError` always counts; so does any error carrying `expected:
15
+ * true` — used to keep the marker alive when an error is serialized across a
16
+ * workflow step boundary and rehydrated as a plain `Error`. Callers log the
17
+ * message alone for expected errors and the full stack for everything else.
18
+ */
19
+ export declare const isExpectedError: (error: unknown) => boolean;
12
20
  /**
13
21
  * Interface for error details.
14
22
  */
@@ -14,6 +14,14 @@ export class PikkuError extends Error {
14
14
  this.name = new.target.name;
15
15
  }
16
16
  }
17
+ /**
18
+ * Whether an error is a deliberate, expected failure rather than an uncaught
19
+ * bug. A `PikkuError` always counts; so does any error carrying `expected:
20
+ * true` — used to keep the marker alive when an error is serialized across a
21
+ * workflow step boundary and rehydrated as a plain `Error`. Callers log the
22
+ * message alone for expected errors and the full stack for everything else.
23
+ */
24
+ export const isExpectedError = (error) => error instanceof PikkuError || error?.expected === true;
17
25
  /**
18
26
  * Adds an error to the API errors map.
19
27
  * @param error - The error to add.
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export type { MCPToolResponse, MCPResourceResponse, MCPPromptResponse, } from '.
19
19
  export { runQueueJob } from './wirings/queue/queue-runner.js';
20
20
  export { runScheduledTask } from './wirings/scheduler/scheduler-runner.js';
21
21
  export { NotFoundError } from './errors/errors.js';
22
+ export { PikkuError, isExpectedError } from './errors/error-handler.js';
22
23
  export type { EventHubService } from './wirings/channel/eventhub-service.js';
23
24
  export type { QueueService } from './wirings/queue/queue.types.js';
24
25
  export type { JWTService } from './services/jwt-service.js';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ export { runMCPTool, runMCPResource, runMCPPrompt, } from './wirings/mcp/mcp-run
11
11
  export { runQueueJob } from './wirings/queue/queue-runner.js';
12
12
  export { runScheduledTask } from './wirings/scheduler/scheduler-runner.js';
13
13
  export { NotFoundError } from './errors/errors.js';
14
+ export { PikkuError, isExpectedError } from './errors/error-handler.js';
14
15
  export { NoopAuditService, createInvocationAudit, resolveAuditActorFromWire, resolveAuditConfig, } from './services/audit-service.js';
15
16
  export { createGraph } from './wirings/workflow/graph/graph-node.js';
16
17
  export { wireAddon } from './wirings/rpc/wire-addon.js';
@@ -1,7 +1,16 @@
1
1
  import type { QueueService, JobOptions } from '../wirings/queue/queue.types.js';
2
+ /**
3
+ * In-process queue for local/dev runs. Schedules jobs on the macrotask queue
4
+ * (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
5
+ * a real queue — and redelivers a failed job up to `options.attempts` times with
6
+ * backoff, so a transiently-failing workflow step recovers exactly as it would
7
+ * on pg-boss/bullmq instead of being silently dropped on its first error.
8
+ */
2
9
  export declare class InMemoryQueueService implements QueueService {
3
10
  readonly supportsResults = false;
4
11
  private jobCounter;
5
12
  add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>;
13
+ /** Delay before the next redelivery, honoring the job's backoff policy. */
14
+ private backoffDelay;
6
15
  getJob(): Promise<null>;
7
16
  }
@@ -1,27 +1,58 @@
1
1
  import { runQueueJob } from '../wirings/queue/queue-runner.js';
2
+ /**
3
+ * In-process queue for local/dev runs. Schedules jobs on the macrotask queue
4
+ * (setTimeout) so dispatch is genuinely asynchronous — the same timing shape as
5
+ * a real queue — and redelivers a failed job up to `options.attempts` times with
6
+ * backoff, so a transiently-failing workflow step recovers exactly as it would
7
+ * on pg-boss/bullmq instead of being silently dropped on its first error.
8
+ */
2
9
  export class InMemoryQueueService {
3
10
  supportsResults = false;
4
11
  jobCounter = 0;
5
12
  async add(queueName, data, options) {
6
13
  const jobId = `inmem-${++this.jobCounter}`;
7
- const job = {
8
- id: jobId,
9
- queueName,
10
- data,
11
- status: () => 'active',
12
- pikkuUserId: options?.pikkuUserId,
13
- };
14
- const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201);
15
- setTimeout(async () => {
14
+ const maxAttempts = Math.max(1, options?.attempts ?? 1);
15
+ let attemptsMade = 0;
16
+ const createdAt = new Date();
17
+ const runAttempt = async () => {
18
+ attemptsMade++;
19
+ const job = {
20
+ id: jobId,
21
+ queueName,
22
+ data,
23
+ status: () => 'active',
24
+ metadata: () => ({ attemptsMade, maxAttempts, createdAt }),
25
+ pikkuUserId: options?.pikkuUserId,
26
+ };
16
27
  try {
17
28
  await runQueueJob({ job });
18
29
  }
19
30
  catch (e) {
20
- console.error(`[InMemoryQueue] Job ${jobId} on ${queueName} failed:`, e.message);
31
+ if (attemptsMade < maxAttempts) {
32
+ // Transient failure — redeliver with backoff, mirroring a real queue.
33
+ setTimeout(runAttempt, this.backoffDelay(options?.backoff, attemptsMade));
34
+ }
35
+ else {
36
+ console.error(`[InMemoryQueue] Job ${jobId} on ${queueName} failed after ${attemptsMade} attempt(s):`, e.message);
37
+ }
21
38
  }
22
- }, delay);
39
+ };
40
+ const delay = options?.delay ?? 100 + Math.floor(Math.random() * 201);
41
+ setTimeout(runAttempt, delay);
23
42
  return jobId;
24
43
  }
44
+ /** Delay before the next redelivery, honoring the job's backoff policy. */
45
+ backoffDelay(backoff, attemptsMade) {
46
+ const baseDelay = typeof backoff === 'object' ? (backoff.delay ?? 50) : 50;
47
+ if (backoff === 'exponential' ||
48
+ (typeof backoff === 'object' && backoff?.type === 'exponential')) {
49
+ return Math.min(2 ** (attemptsMade - 1) * baseDelay, 2000);
50
+ }
51
+ if (typeof backoff === 'object' && backoff?.type === 'fixed') {
52
+ return backoff.delay ?? 50;
53
+ }
54
+ return 50;
55
+ }
25
56
  async getJob() {
26
57
  return null;
27
58
  }
@@ -1,6 +1,6 @@
1
1
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
2
2
  import type { SerializedError } from '../types/core.types.js';
3
- import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
3
+ import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
4
4
  /**
5
5
  * In-memory implementation of WorkflowService for inline-only execution
6
6
  *
@@ -35,7 +35,7 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
35
35
  stepName: string;
36
36
  }>>;
37
37
  protected updateRunStatusImpl(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
38
- protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions): Promise<StepState>;
38
+ protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
39
39
  getStepState(runId: string, stepName: string): Promise<StepState>;
40
40
  protected setStepRunningImpl(stepId: string): Promise<void>;
41
41
  protected setStepScheduledImpl(stepId: string): Promise<void>;
@@ -65,6 +65,11 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
65
65
  branchKeys: Record<string, string>;
66
66
  }>;
67
67
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
68
+ getStepInstances(runId: string): Promise<Array<{
69
+ stepName: string;
70
+ status: StepStatus;
71
+ fromStepName?: string;
72
+ }>>;
68
73
  getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
69
74
  setBranchKey(runId: string, nodeId: string, branchKey: string): Promise<void>;
70
75
  protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
3
+ import { isExpectedError } from '../errors/error-handler.js';
3
4
  /**
4
5
  * In-memory implementation of WorkflowService for inline-only execution
5
6
  *
@@ -78,7 +79,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
78
79
  run.error = error;
79
80
  }
80
81
  }
81
- async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
82
+ async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
82
83
  const stepId = randomUUID();
83
84
  const now = new Date();
84
85
  const step = {
@@ -87,6 +88,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
87
88
  attemptCount: 1,
88
89
  retries: stepOptions?.retries,
89
90
  retryDelay: stepOptions?.retryDelay,
91
+ fromStepName,
90
92
  createdAt: now,
91
93
  updatedAt: now,
92
94
  stepName,
@@ -147,6 +149,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
147
149
  name: error.name,
148
150
  message: error.message,
149
151
  stack: error.stack,
152
+ expected: isExpectedError(error),
150
153
  };
151
154
  step.failedAt = new Date();
152
155
  step.updatedAt = new Date();
@@ -189,6 +192,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
189
192
  attemptCount: failedStep.attemptCount + 1,
190
193
  retries: failedStep.retries,
191
194
  retryDelay: failedStep.retryDelay,
195
+ fromStepName: failedStep.fromStepName,
192
196
  createdAt: now,
193
197
  updatedAt: now,
194
198
  stepName: stepName,
@@ -317,6 +321,20 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
317
321
  }
318
322
  return nodeIds.filter((id) => !existingSteps.has(id));
319
323
  }
324
+ async getStepInstances(runId) {
325
+ const prefix = `${runId}:`;
326
+ const instances = [];
327
+ for (const [key, step] of this.steps.entries()) {
328
+ if (!key.startsWith(prefix))
329
+ continue;
330
+ instances.push({
331
+ stepName: key.substring(prefix.length),
332
+ status: step.status,
333
+ fromStepName: step.fromStepName,
334
+ });
335
+ }
336
+ return instances;
337
+ }
320
338
  async getNodeResults(runId, nodeIds) {
321
339
  const results = {};
322
340
  for (const nodeId of nodeIds) {
@@ -1,5 +1,6 @@
1
1
  import type { SerializedError } from '../types/core.types.js';
2
2
  import type { WorkflowRun, WorkflowPlannedStep, WorkflowRunWire, WorkflowRunStatus, StepState, WorkflowStatus, WorkflowVersionStatus } from '../wirings/workflow/workflow.types.js';
3
+ import type { RunTimeline, ReconstructedRunState } from '../wirings/workflow/run-timeline.js';
3
4
  /**
4
5
  * Interface for workflow orchestration
5
6
  * Handles workflow execution, replay, orchestration logic, and run-level state
@@ -14,6 +15,8 @@ export interface WorkflowService {
14
15
  getRunHistory(runId: string): Promise<Array<StepState & {
15
16
  stepName: string;
16
17
  }>>;
18
+ getRunTimeline(id: string): Promise<RunTimeline | null>;
19
+ reconstructRunStateAt(id: string, at?: number | Date): Promise<ReconstructedRunState | null>;
17
20
  updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
18
21
  withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
19
22
  close(): Promise<void>;
@@ -175,6 +175,41 @@ export function defineServiceTests(config) {
175
175
  assert.equal(retried.error, undefined);
176
176
  assert.equal(retried.result, undefined);
177
177
  });
178
+ test('getRunStatus reports per-step duration and attempts', async () => {
179
+ const runId = await service.createRun('status-timing-workflow', {}, false, 'hash-timing', wire);
180
+ const step = await service.insertStepState(runId, 'timed-step', 'myRpc', {});
181
+ await service.setStepRunning(step.stepId);
182
+ await service.setStepResult(step.stepId, { ok: true });
183
+ const status = await service.getRunStatus(runId);
184
+ assert.ok(status);
185
+ const timed = status.steps.find((s) => s.name === 'timed-step');
186
+ assert.ok(timed, 'step appears in run status');
187
+ // running→succeeded must stamp runningAt/succeededAt so a duration is
188
+ // computable. Regression guard: the kysely store previously updated
189
+ // only the status on transitions, leaving the timestamps null and
190
+ // duration permanently undefined.
191
+ assert.ok(timed.duration !== undefined, 'duration is computed from stamped transition timestamps');
192
+ assert.ok(timed.duration >= 0, 'duration is non-negative');
193
+ assert.equal(timed.attempts, 1, 'single attempt');
194
+ });
195
+ test('getRunStatus surfaces retried attempt count', async () => {
196
+ const runId = await service.createRun('status-retry-workflow', {}, false, 'hash-retry-status', wire);
197
+ const step = await service.insertStepState(runId, 'flaky-step', 'myRpc', {}, { retries: 2 });
198
+ await service.setStepRunning(step.stepId);
199
+ await service.setStepError(step.stepId, new Error('first try'));
200
+ // Guarantee the retry's history row sorts after the failed attempt so
201
+ // getRunStatus picks the latest attempt (it tie-breaks on timestamps).
202
+ await new Promise((resolve) => setTimeout(resolve, 5));
203
+ const retry = await service.createRetryAttempt(step.stepId, 'pending');
204
+ await service.setStepRunning(retry.stepId);
205
+ await service.setStepResult(retry.stepId, { ok: true });
206
+ const status = await service.getRunStatus(runId);
207
+ assert.ok(status);
208
+ const flaky = status.steps.find((s) => s.name === 'flaky-step');
209
+ assert.ok(flaky, 'retried step collapses to a single status entry');
210
+ assert.equal(flaky.status, 'succeeded', 'latest attempt wins');
211
+ assert.equal(flaky.attempts, 2, 'attempt count surfaces in status');
212
+ });
178
213
  test('getNodesWithoutSteps', async () => {
179
214
  const runId = await service.createRun('graph-workflow', {}, false, 'hash-7', wire);
180
215
  await service.insertStepState(runId, 'existing-node', 'rpc', {});
@@ -378,5 +378,6 @@ export interface SerializedError {
378
378
  message: string;
379
379
  stack?: string;
380
380
  code?: string;
381
+ expected?: boolean;
381
382
  [key: string]: any;
382
383
  }
@@ -1,4 +1,5 @@
1
1
  import { NotFoundError } from '../../errors/errors.js';
2
+ import { isExpectedError } from '../../errors/error-handler.js';
2
3
  import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
3
4
  import { pikkuState } from '../../pikku-state.js';
4
5
  import { PikkuSessionService, createMiddlewareSessionWireProps, } from '../../services/user-session-service.js';
@@ -361,9 +362,17 @@ export async function executeCLI({ programName, args, createConfig, createSingle
361
362
  if (error instanceof CLIError) {
362
363
  throw error;
363
364
  }
364
- // Wrap other errors in CLIError
365
- console.error('Error:', error);
366
- // Show stack trace in verbose mode
365
+ // An expected failure (a deliberate PikkuError, e.g. a build gate
366
+ // tripping) — its message says everything, so print that alone (no
367
+ // `Error:` prefix). Anything else is an uncaught error: log the object so
368
+ // its stack shows.
369
+ if (isExpectedError(error)) {
370
+ console.error(error.message);
371
+ }
372
+ else {
373
+ console.error('Error:', error);
374
+ }
375
+ // Show stack trace in verbose mode (even for expected errors).
367
376
  if (args.includes('--verbose') || args.includes('-v')) {
368
377
  console.error('Stack trace:', error.stack);
369
378
  }
@@ -271,10 +271,28 @@ export type WorkflowStepMeta = RpcStepMeta | BranchStepMeta | ParallelGroupStepM
271
271
  export interface WorkflowStepWire {
272
272
  /** The workflow run ID */
273
273
  runId: string;
274
- /** The unique step ID */
274
+ /**
275
+ * The step row ID. Whether it stays the same or is minted fresh per attempt is
276
+ * STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
277
+ * the row) — do NOT use it as a dedupe key. Use `invocationId`.
278
+ */
275
279
  stepId: string;
280
+ /**
281
+ * Stable identity of this step invocation — the idempotency / dedupe key.
282
+ * Identical across every retry of the same call (derived from runId + step
283
+ * name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
284
+ * or pass it as an external idempotency key and have retries collapse onto the
285
+ * first attempt.
286
+ */
287
+ invocationId: string;
276
288
  /** Current attempt number (1-indexed, increments on retry) */
277
289
  attemptCount: number;
290
+ /**
291
+ * Invocation ID of the predecessor step this one was reached from (the walked
292
+ * transition/edge). Undefined for entry steps. Lets a step know its origin —
293
+ * e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
294
+ */
295
+ fromInvocationId?: string;
278
296
  }
279
297
  /**
280
298
  * Workflow wire object for DSL workflows