@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,56 @@
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
+
1
54
  ## 0.12.38
2
55
 
3
56
  ### 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,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
  *
@@ -148,6 +149,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
148
149
  name: error.name,
149
150
  message: error.message,
150
151
  stack: error.stack,
152
+ expected: isExpectedError(error),
151
153
  };
152
154
  step.failedAt = new Date();
153
155
  step.updatedAt = new Date();
@@ -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
  }
@@ -409,7 +409,14 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
409
409
  await queueGraphNode(workflowService, runId, graphName, fire.instanceKey, node.rpcName, resolvedInput, node, fire.fromStepName);
410
410
  }
411
411
  }
412
- export async function executeGraphStep(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName) {
412
+ /**
413
+ * Invoke a graph node's RPC with the graph + workflow wires, capturing any
414
+ * branch the node selects and persisting it. Shared by the queued
415
+ * (executeGraphStep) and inline (executeGraphNodeInline) executors so both build
416
+ * the wire and record the branch identically — only their child-workflow and
417
+ * onError handling differs around this call.
418
+ */
419
+ async function invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, nodeId, rpcName, input, graphName) {
413
420
  const wireState = {};
414
421
  const graphWire = {
415
422
  runId,
@@ -421,6 +428,16 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
421
428
  setState: (name, value) => workflowService.updateRunState(runId, name, value),
422
429
  getState: () => workflowService.getRunState(runId),
423
430
  };
431
+ const result = await rpcService.rpcWithWire(rpcName, input, {
432
+ graph: graphWire,
433
+ workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
434
+ });
435
+ if (wireState.branchKey) {
436
+ await workflowService.setBranchTaken(stepId, wireState.branchKey);
437
+ }
438
+ return result;
439
+ }
440
+ export async function executeGraphStep(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName) {
424
441
  try {
425
442
  let result;
426
443
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
@@ -449,13 +466,7 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
449
466
  }
450
467
  }
451
468
  else {
452
- result = await rpcService.rpcWithWire(rpcName, data, {
453
- graph: graphWire,
454
- workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
455
- });
456
- }
457
- if (wireState.branchKey) {
458
- await workflowService.setBranchTaken(stepId, wireState.branchKey);
469
+ result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName);
459
470
  }
460
471
  return result;
461
472
  }
@@ -499,24 +510,16 @@ export async function onGraphNodeComplete(workflowService, runId, graphName) {
499
510
  export async function runFromMeta(workflowService, runId, meta, _rpcService) {
500
511
  await continueGraph(workflowService, runId, meta.name, meta);
501
512
  }
502
- async function executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, input, nodes) {
513
+ async function executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, instanceKey, input, nodes, fromStepName) {
503
514
  const node = nodes[nodeId];
504
515
  if (!node)
505
516
  return;
506
517
  const rpcName = node.rpcName;
507
- const stepState = await workflowService.insertStepState(runId, nodeId, rpcName, input, { retries: node.retries ?? 0, retryDelay: node.retryDelay });
518
+ // Persist under the physical instance key (node, node#1 for revisits) and
519
+ // record the predecessor — same as the queued path (queueGraphNode), so an
520
+ // inline graph run stores identical step rows + provenance.
521
+ const stepState = await workflowService.insertStepState(runId, instanceKey, rpcName, input, { retries: node.retries ?? 0, retryDelay: node.retryDelay }, fromStepName);
508
522
  await workflowService.setStepRunning(stepState.stepId);
509
- const wireState = {};
510
- const graphWire = {
511
- runId,
512
- graphName,
513
- nodeId,
514
- branch: (key) => {
515
- wireState.branchKey = key;
516
- },
517
- setState: (name, value) => workflowService.updateRunState(runId, name, value),
518
- getState: () => workflowService.getRunState(runId),
519
- };
520
523
  try {
521
524
  let result;
522
525
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
@@ -539,13 +542,7 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
539
542
  result = childRun?.output;
540
543
  }
541
544
  else {
542
- result = await rpcService.rpcWithWire(rpcName, input, {
543
- graph: graphWire,
544
- workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
545
- });
546
- }
547
- if (wireState.branchKey) {
548
- await workflowService.setBranchTaken(stepState.stepId, wireState.branchKey);
545
+ result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepState.stepId, nodeId, rpcName, input, graphName);
549
546
  }
550
547
  await workflowService.setStepResult(stepState.stepId, result);
551
548
  }
@@ -567,19 +564,25 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
567
564
  const errorNodes = Array.isArray(node.onError)
568
565
  ? node.onError
569
566
  : [node.onError];
570
- await Promise.all(errorNodes.map((errorNodeId) => executeGraphNodeInline(workflowService, rpcService, runId, graphName, errorNodeId, { error: { message: error.message } }, nodes)));
567
+ await Promise.all(errorNodes.map((errorNodeId) => executeGraphNodeInline(workflowService, rpcService, runId, graphName, errorNodeId, errorNodeId, { error: { message: error.message } }, nodes, nodeId)));
571
568
  return;
572
569
  }
573
570
  throw error;
574
571
  }
575
572
  }
576
573
  async function continueGraphInline(workflowService, rpcService, runId, graphName, nodes, triggerInput, entryNodeIds) {
574
+ // Drive the run to completion in-process using the SAME planner as the queued
575
+ // path (continueGraph): each loop plans the next wave of transitions, executes
576
+ // them inline (vs queueGraphNode dispatch), then re-plans. Sharing the planner
577
+ // gives the inline path joins, cycle revisits and fromStepName provenance
578
+ // identical to the queue — instead of a second, weaker traversal.
577
579
  while (true) {
578
- const { completedNodeIds: rawCompleted, failedNodeIds: rawFailed, branchKeys: rawBranch, } = await workflowService.getCompletedGraphState(runId);
579
- const completedNodeIds = remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
580
- const completedNodeIdSet = new Set(completedNodeIds);
580
+ const { failedNodeIds: rawFailed, branchKeys: branchByStep, completedNodeIds: rawCompleted, } = await workflowService.getCompletedGraphState(runId);
581
+ // Validate step/branch names map to unambiguous nodes (planning keys
582
+ // physically; these calls only surface ambiguous template configs).
583
+ remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
584
+ remapBranchKeys(branchByStep, nodes, graphName);
581
585
  const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
582
- const branchKeys = remapBranchKeys(rawBranch, nodes, graphName);
583
586
  if (failedNodeIds.length > 0) {
584
587
  const failedNode = failedNodeIds[0];
585
588
  await workflowService.updateRunStatus(runId, 'failed', undefined, {
@@ -589,46 +592,33 @@ async function continueGraphInline(workflowService, rpcService, runId, graphName
589
592
  });
590
593
  return;
591
594
  }
592
- const candidateNodes = new Set();
593
- for (const nodeId of completedNodeIds) {
594
- const node = nodes[nodeId];
595
- if (!node?.next)
596
- continue;
597
- const nextNodes = resolveNextFromConfig(node.next, branchKeys[nodeId]);
598
- for (const nextNode of nextNodes) {
599
- candidateNodes.add(nextNode);
600
- }
601
- }
602
- for (const entryId of entryNodeIds) {
603
- candidateNodes.add(entryId);
604
- }
605
- if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
606
- await workflowService.updateRunStatus(runId, 'completed');
595
+ const run = await workflowService.getRun(runId);
596
+ if (run?.status === 'suspended') {
607
597
  return;
608
598
  }
609
- const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
610
- ...candidateNodes,
611
- ]);
612
- const nodesToExecute = unstartedNodes.filter((nodeId) => {
613
- const node = nodes[nodeId];
614
- return node && areDependenciesSatisfied(node, completedNodeIdSet);
615
- });
616
- if (nodesToExecute.length === 0) {
617
- if (completedNodeIds.length > 0 && unstartedNodes.length === 0) {
599
+ const instances = await workflowService.getStepInstances(runId);
600
+ const plan = planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName);
601
+ if (plan.toFire.length === 0) {
602
+ if (!plan.hasInFlight && !plan.blockedWaiting) {
618
603
  await workflowService.updateRunStatus(runId, 'completed');
619
604
  }
620
605
  return;
621
606
  }
622
- await Promise.all(nodesToExecute.map(async (nodeId) => {
623
- const node = nodes[nodeId];
607
+ let executed = 0;
608
+ await Promise.all(plan.toFire.map(async (fire) => {
609
+ const node = nodes[fire.logical];
624
610
  if (!node?.rpcName)
625
611
  return;
626
612
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
627
613
  const fetchedResults = await workflowService.getNodeResults(runId, referencedNodeIds);
628
614
  const nodeResults = { trigger: triggerInput, ...fetchedResults };
629
615
  const resolvedInput = resolveSerializedInput(node.input, nodeResults);
630
- await executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, resolvedInput, nodes);
616
+ executed++;
617
+ await executeGraphNodeInline(workflowService, rpcService, runId, graphName, fire.logical, fire.instanceKey, resolvedInput, nodes, fire.fromStepName);
631
618
  }));
619
+ // Nothing executable fired (e.g. nodes without an rpcName) → can't progress.
620
+ if (executed === 0)
621
+ return;
632
622
  }
633
623
  }
634
624
  export async function runWorkflowGraph(workflowService, graphName, triggerInput, rpcService, inline, startNode, wire, overrideMeta) {
@@ -672,7 +662,7 @@ export async function runWorkflowGraph(workflowService, graphName, triggerInput,
672
662
  const resolvedInput = node.input && Object.keys(node.input).length > 0
673
663
  ? resolveSerializedInput(node.input, triggerNodeResults)
674
664
  : triggerInput;
675
- await executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, resolvedInput, nodes);
665
+ await executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, nodeId, resolvedInput, nodes);
676
666
  }));
677
667
  await continueGraphInline(workflowService, rpcService, runId, graphName, nodes, triggerInput, entryNodes);
678
668
  }
@@ -3,6 +3,8 @@
3
3
  */
4
4
  export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
5
5
  export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
6
+ export { buildRunTimeline, reconstructStateAt, reconstructFinalState, } from './run-timeline.js';
7
+ export type { RunTimeline, RunTimelineEvent, ReconstructedRunState, ReconstructedStep, RunPhase, } from './run-timeline.js';
6
8
  export { addWorkflow } from './dsl/workflow-runner.js';
7
9
  export { template, type TemplateString } from './graph/template.js';
8
10
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
@@ -3,6 +3,8 @@
3
3
  */
4
4
  export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
5
5
  export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js';
6
+ // Time-travel: reconstruct run state at any point from durable history
7
+ export { buildRunTimeline, reconstructStateAt, reconstructFinalState, } from './run-timeline.js';
6
8
  // Internal registration functions (used by generated code)
7
9
  export { addWorkflow } from './dsl/workflow-runner.js';
8
10
  // Graph helpers (template, pikkuWorkflowGraph)
@@ -2,6 +2,7 @@ import type { SerializedError } from '../../types/core.types.js';
2
2
  import type { PikkuWorkflowWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
3
3
  import type { WorkflowService } from '../../services/workflow-service.js';
4
4
  import { PikkuError } from '../../errors/error-handler.js';
5
+ import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
5
6
  import type { JobOptions } from '../queue/queue.types.js';
6
7
  /**
7
8
  * Default number of retries for a workflow step when none is specified. The
@@ -123,6 +124,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
123
124
  * Used by the public API — the console addon provides the full verbose view.
124
125
  */
125
126
  getRunStatus(id: string): Promise<WorkflowRunStatus | null>;
127
+ /**
128
+ * Build the run's time-travel event stream from durable history.
129
+ * @param id - Run ID
130
+ * @returns Ordered timeline, or null if the run doesn't exist
131
+ */
132
+ getRunTimeline(id: string): Promise<RunTimeline | null>;
133
+ /**
134
+ * Reconstruct the run's state at a point in its timeline.
135
+ * @param id - Run ID
136
+ * @param at - A seq index (inclusive) or a Date (inclusive); omit for the
137
+ * final state.
138
+ * @returns Reconstructed state, or null if the run doesn't exist
139
+ */
140
+ reconstructRunStateAt(id: string, at?: number | Date): Promise<ReconstructedRunState | null>;
126
141
  /**
127
142
  * Get workflow run history (all step attempts in chronological order)
128
143
  * @param runId - Run ID
@@ -390,6 +405,23 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
390
405
  */
391
406
  orchestrateWorkflow(runId: string, rpcService: any): Promise<void>;
392
407
  private verifyQueueService;
408
+ /**
409
+ * Invoke a step's RPC with the workflow-step wire (step identity + provenance).
410
+ * Identical for the queue executor and the inline executor — the only thing
411
+ * that differs between transports is who calls it, not the call itself.
412
+ */
413
+ private invokeStepRpc;
414
+ /**
415
+ * Inline (straight-through) step execution with an in-process retry loop —
416
+ * shared by inline RPC steps and inline function steps. Same scaffolding
417
+ * (running → result, or fail → retry-attempt → backoff → retry) wrapped
418
+ * around a step-specific `doWork` body. Stays O(K): no suspend/replay.
419
+ *
420
+ * `onError` is an optional hook for terminal errors that must NOT retry
421
+ * (e.g. RPC-not-found → suspend the run for redeploy). If it throws, the
422
+ * loop exits immediately without recording a step error or retrying.
423
+ */
424
+ private runInlineRetryLoop;
393
425
  private rpcStep;
394
426
  private inlineStep;
395
427
  private sleepStep;