@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
@@ -1,4 +1,4 @@
1
- import { WorkflowAsyncException, WorkflowSuspendedException, } from '../pikku-workflow-service.js';
1
+ import { WorkflowAsyncException, WorkflowSuspendedException, DEFAULT_STEP_RETRIES, } from '../pikku-workflow-service.js';
2
2
  import { pikkuState, getSingletonServices } from '../../../pikku-state.js';
3
3
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js';
4
4
  export class ChildWorkflowStartedException extends Error {
@@ -22,6 +22,13 @@ function buildTemplateRegex(nodeId) {
22
22
  .join('.+');
23
23
  return new RegExp(`^${escaped}$`);
24
24
  }
25
+ /** Strip a trailing revisit ordinal (`node#2` → `node`); leaves other names as-is. */
26
+ function stripInstanceOrdinal(name) {
27
+ const hash = name.lastIndexOf('#');
28
+ if (hash <= 0)
29
+ return name;
30
+ return /^\d+$/.test(name.slice(hash + 1)) ? name.slice(0, hash) : name;
31
+ }
25
32
  function remapStepNamesToNodeIds(stepNames, nodes, graphName) {
26
33
  const templatePatterns = new Map();
27
34
  for (const nodeId of Object.keys(nodes)) {
@@ -29,14 +36,16 @@ function remapStepNamesToNodeIds(stepNames, nodes, graphName) {
29
36
  if (regex)
30
37
  templatePatterns.set(nodeId, regex);
31
38
  }
32
- if (templatePatterns.size === 0)
33
- return stepNames;
34
39
  return stepNames.map((name) => {
35
40
  if (nodes[name])
36
41
  return name;
42
+ // Revisit instance (`node#N`) maps to its logical node.
43
+ const base = stripInstanceOrdinal(name);
44
+ if (base !== name && nodes[base])
45
+ return base;
37
46
  const matches = [];
38
47
  for (const [nodeId, regex] of templatePatterns) {
39
- if (regex.test(name))
48
+ if (regex.test(base))
40
49
  matches.push(nodeId);
41
50
  }
42
51
  if (matches.length > 1) {
@@ -48,6 +57,91 @@ function remapStepNamesToNodeIds(stepNames, nodes, graphName) {
48
57
  return name;
49
58
  });
50
59
  }
60
+ const ENTRY_FROM = '__entry__';
61
+ /** Whether `target` can reach `source` over `next` edges — i.e. an edge
62
+ * source→target closes a cycle (a back-edge), vs a plain forward edge. */
63
+ function closesCycle(source, target, nodes) {
64
+ const seen = new Set();
65
+ const stack = [target];
66
+ while (stack.length) {
67
+ const cur = stack.pop();
68
+ if (cur === source)
69
+ return true;
70
+ if (seen.has(cur))
71
+ continue;
72
+ seen.add(cur);
73
+ for (const next of normalizeNodeTargets(nodes[cur]?.next))
74
+ stack.push(next);
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Decide which next steps to fire this tick. Two kinds of edge:
80
+ * - forward edge → node-once: fire the target only if it has no instance yet,
81
+ * so converging edges (joins) collapse to a single run (unchanged behavior).
82
+ * - back-edge (target can reach the source, closing a cycle) → revisit: fire a
83
+ * fresh ordinal instance (`target#1`, …), edge-once on `from → target` so it
84
+ * doesn't re-fire every tick. Cycles terminate when branch routing stops
85
+ * looping back; a node always records the predecessor it was reached from.
86
+ */
87
+ function planGraphTransitions(nodes, instances, branchByStep, entryNodeIds, graphName) {
88
+ const toLogical = (name) => remapStepNamesToNodeIds([name], nodes, graphName)[0];
89
+ const countByLogical = {};
90
+ const consumed = new Set();
91
+ for (const inst of instances) {
92
+ const logical = toLogical(inst.stepName);
93
+ countByLogical[logical] = (countByLogical[logical] ?? 0) + 1;
94
+ consumed.add(`${inst.fromStepName ?? ENTRY_FROM}->${logical}`);
95
+ }
96
+ const completed = instances.filter((i) => i.status === 'succeeded');
97
+ const completedLogical = new Set(completed.map((i) => toLogical(i.stepName)));
98
+ // Available edges: entry edges + each completed instance's resolved `next`.
99
+ const edges = [];
100
+ for (const entryId of entryNodeIds) {
101
+ edges.push({ fromKey: ENTRY_FROM, target: entryId });
102
+ }
103
+ for (const inst of completed) {
104
+ const fromLogical = toLogical(inst.stepName);
105
+ const node = nodes[fromLogical];
106
+ if (!node?.next)
107
+ continue;
108
+ for (const target of resolveNextFromConfig(node.next, branchByStep[inst.stepName])) {
109
+ edges.push({ from: inst.stepName, fromKey: inst.stepName, fromLogical, target });
110
+ }
111
+ }
112
+ const toFire = [];
113
+ let blockedWaiting = false;
114
+ for (const edge of edges) {
115
+ const target = edge.target;
116
+ const edgeKey = `${edge.fromKey}->${target}`;
117
+ if (consumed.has(edgeKey))
118
+ continue;
119
+ const visits = countByLogical[target] ?? 0;
120
+ const isBackEdge = edge.fromLogical !== undefined &&
121
+ closesCycle(edge.fromLogical, target, nodes);
122
+ // Forward edge into an already-started node = a join; node-once.
123
+ if (!isBackEdge && visits > 0) {
124
+ consumed.add(edgeKey);
125
+ continue;
126
+ }
127
+ if (!areDependenciesSatisfied(nodes[target] ?? {}, completedLogical)) {
128
+ blockedWaiting = true;
129
+ continue;
130
+ }
131
+ toFire.push({
132
+ logical: target,
133
+ instanceKey: visits === 0 ? target : `${target}#${visits}`,
134
+ fromStepName: edge.from,
135
+ });
136
+ countByLogical[target] = visits + 1;
137
+ consumed.add(edgeKey);
138
+ }
139
+ return {
140
+ toFire,
141
+ hasInFlight: instances.some((i) => i.status !== 'succeeded'),
142
+ blockedWaiting,
143
+ };
144
+ }
51
145
  function remapBranchKeys(branchKeys, nodes, graphName) {
52
146
  const templatePatterns = new Map();
53
147
  for (const nodeId of Object.keys(nodes)) {
@@ -257,13 +351,15 @@ function areDependenciesSatisfied(node, completedNodeIds) {
257
351
  const deps = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
258
352
  return deps.every((dep) => completedNodeIds.has(dep));
259
353
  }
260
- async function queueGraphNode(workflowService, runId, _graphName, nodeId, rpcName, input, nodeConfig) {
354
+ async function queueGraphNode(workflowService, runId, _graphName, nodeId, rpcName, input, nodeConfig, fromStepName) {
355
+ // Default to the workflow-wide retry policy when the node sets none, so the
356
+ // persisted step retries match the queue `attempts` (see resolveStepJobOptions).
261
357
  const stepOptions = {
262
- retries: nodeConfig?.retries ?? 0,
358
+ retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
263
359
  retryDelay: nodeConfig?.retryDelay,
264
360
  };
265
- await workflowService.insertStepState(runId, nodeId, rpcName, input, stepOptions);
266
- await workflowService.queueStepWorker(runId, nodeId, rpcName, input, stepOptions);
361
+ await workflowService.insertStepState(runId, nodeId, rpcName, input, stepOptions, fromStepName);
362
+ await workflowService.queueStepWorker(runId, nodeId, rpcName, input, stepOptions, fromStepName);
267
363
  }
268
364
  export async function continueGraph(workflowService, runId, graphName, overrideMeta) {
269
365
  const meta = overrideMeta ?? getWorkflowMeta(graphName);
@@ -272,11 +368,12 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
272
368
  }
273
369
  const nodes = meta.nodes;
274
370
  validateGraphReferences(graphName, nodes, meta.entryNodeIds ?? []);
275
- const { completedNodeIds: rawCompleted, failedNodeIds: rawFailed, branchKeys: rawBranch, } = await workflowService.getCompletedGraphState(runId);
276
- const completedNodeIds = remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
277
- const completedNodeIdSet = new Set(completedNodeIds);
371
+ const { completedNodeIds: rawCompleted, failedNodeIds: rawFailed, branchKeys: branchByStep, } = await workflowService.getCompletedGraphState(runId);
372
+ // Validate step/branch names map to unambiguous nodes (planning keys
373
+ // physically; these calls only surface ambiguous template configs).
374
+ remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
375
+ remapBranchKeys(branchByStep, nodes, graphName);
278
376
  const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
279
- const branchKeys = remapBranchKeys(rawBranch, nodes, graphName);
280
377
  if (failedNodeIds.length > 0) {
281
378
  const failedNode = failedNodeIds[0];
282
379
  await workflowService.updateRunStatus(runId, 'failed', undefined, {
@@ -290,54 +387,36 @@ export async function continueGraph(workflowService, runId, graphName, overrideM
290
387
  if (currentRun?.status === 'suspended') {
291
388
  return;
292
389
  }
293
- const candidateNodes = new Set();
294
- for (const nodeId of completedNodeIds) {
295
- const node = nodes[nodeId];
296
- if (!node?.next)
297
- continue;
298
- const nextNodes = resolveNextFromConfig(node.next, branchKeys[nodeId]);
299
- for (const nextNode of nextNodes) {
300
- candidateNodes.add(nextNode);
301
- }
302
- }
303
- for (const entryId of meta.entryNodeIds ?? []) {
304
- candidateNodes.add(entryId);
305
- }
306
- if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
307
- await workflowService.updateRunStatus(runId, 'completed');
308
- return;
309
- }
310
- const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
311
- ...candidateNodes,
312
- ]);
313
- const nodesToQueue = unstartedNodes.filter((nodeId) => {
314
- const node = nodes[nodeId];
315
- return node && areDependenciesSatisfied(node, completedNodeIdSet);
316
- });
317
- if (nodesToQueue.length === 0) {
318
- const allRpcNodes = Object.entries(nodes)
319
- .filter(([_, n]) => n.rpcName)
320
- .map(([id]) => id);
321
- const allRpcCompleted = allRpcNodes.every((id) => completedNodeIdSet.has(id));
322
- if (allRpcCompleted) {
390
+ const instances = await workflowService.getStepInstances(runId);
391
+ const plan = planGraphTransitions(nodes, instances, branchByStep, meta.entryNodeIds ?? [], graphName);
392
+ if (plan.toFire.length === 0) {
393
+ // Nothing left to fire and nothing running/blocked → the run is done.
394
+ if (!plan.hasInFlight && !plan.blockedWaiting) {
323
395
  await workflowService.updateRunStatus(runId, 'completed');
324
396
  }
325
397
  return;
326
398
  }
327
399
  const run = await workflowService.getRun(runId);
328
400
  const triggerInput = run?.input;
329
- for (const nodeId of nodesToQueue) {
330
- const node = nodes[nodeId];
401
+ for (const fire of plan.toFire) {
402
+ const node = nodes[fire.logical];
331
403
  if (!node?.rpcName)
332
404
  continue;
333
405
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
334
406
  const fetchedResults = await workflowService.getNodeResults(runId, referencedNodeIds);
335
407
  const nodeResults = { trigger: triggerInput, ...fetchedResults };
336
408
  const resolvedInput = resolveSerializedInput(node.input, nodeResults);
337
- await queueGraphNode(workflowService, runId, graphName, nodeId, node.rpcName, resolvedInput, node);
409
+ await queueGraphNode(workflowService, runId, graphName, fire.instanceKey, node.rpcName, resolvedInput, node, fire.fromStepName);
338
410
  }
339
411
  }
340
- 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) {
341
420
  const wireState = {};
342
421
  const graphWire = {
343
422
  runId,
@@ -349,6 +428,16 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
349
428
  setState: (name, value) => workflowService.updateRunState(runId, name, value),
350
429
  getState: () => workflowService.getRunState(runId),
351
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) {
352
441
  try {
353
442
  let result;
354
443
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
@@ -377,13 +466,7 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
377
466
  }
378
467
  }
379
468
  else {
380
- result = await rpcService.rpcWithWire(rpcName, data, {
381
- graph: graphWire,
382
- workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
383
- });
384
- }
385
- if (wireState.branchKey) {
386
- await workflowService.setBranchTaken(stepId, wireState.branchKey);
469
+ result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName);
387
470
  }
388
471
  return result;
389
472
  }
@@ -427,24 +510,16 @@ export async function onGraphNodeComplete(workflowService, runId, graphName) {
427
510
  export async function runFromMeta(workflowService, runId, meta, _rpcService) {
428
511
  await continueGraph(workflowService, runId, meta.name, meta);
429
512
  }
430
- async function executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, input, nodes) {
513
+ async function executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, instanceKey, input, nodes, fromStepName) {
431
514
  const node = nodes[nodeId];
432
515
  if (!node)
433
516
  return;
434
517
  const rpcName = node.rpcName;
435
- 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);
436
522
  await workflowService.setStepRunning(stepState.stepId);
437
- const wireState = {};
438
- const graphWire = {
439
- runId,
440
- graphName,
441
- nodeId,
442
- branch: (key) => {
443
- wireState.branchKey = key;
444
- },
445
- setState: (name, value) => workflowService.updateRunState(runId, name, value),
446
- getState: () => workflowService.getRunState(runId),
447
- };
448
523
  try {
449
524
  let result;
450
525
  const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
@@ -467,13 +542,7 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
467
542
  result = childRun?.output;
468
543
  }
469
544
  else {
470
- result = await rpcService.rpcWithWire(rpcName, input, {
471
- graph: graphWire,
472
- workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
473
- });
474
- }
475
- if (wireState.branchKey) {
476
- await workflowService.setBranchTaken(stepState.stepId, wireState.branchKey);
545
+ result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepState.stepId, nodeId, rpcName, input, graphName);
477
546
  }
478
547
  await workflowService.setStepResult(stepState.stepId, result);
479
548
  }
@@ -495,19 +564,25 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
495
564
  const errorNodes = Array.isArray(node.onError)
496
565
  ? node.onError
497
566
  : [node.onError];
498
- 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)));
499
568
  return;
500
569
  }
501
570
  throw error;
502
571
  }
503
572
  }
504
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.
505
579
  while (true) {
506
- const { completedNodeIds: rawCompleted, failedNodeIds: rawFailed, branchKeys: rawBranch, } = await workflowService.getCompletedGraphState(runId);
507
- const completedNodeIds = remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
508
- 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);
509
585
  const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName);
510
- const branchKeys = remapBranchKeys(rawBranch, nodes, graphName);
511
586
  if (failedNodeIds.length > 0) {
512
587
  const failedNode = failedNodeIds[0];
513
588
  await workflowService.updateRunStatus(runId, 'failed', undefined, {
@@ -517,46 +592,33 @@ async function continueGraphInline(workflowService, rpcService, runId, graphName
517
592
  });
518
593
  return;
519
594
  }
520
- const candidateNodes = new Set();
521
- for (const nodeId of completedNodeIds) {
522
- const node = nodes[nodeId];
523
- if (!node?.next)
524
- continue;
525
- const nextNodes = resolveNextFromConfig(node.next, branchKeys[nodeId]);
526
- for (const nextNode of nextNodes) {
527
- candidateNodes.add(nextNode);
528
- }
529
- }
530
- for (const entryId of entryNodeIds) {
531
- candidateNodes.add(entryId);
532
- }
533
- if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
534
- await workflowService.updateRunStatus(runId, 'completed');
595
+ const run = await workflowService.getRun(runId);
596
+ if (run?.status === 'suspended') {
535
597
  return;
536
598
  }
537
- const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
538
- ...candidateNodes,
539
- ]);
540
- const nodesToExecute = unstartedNodes.filter((nodeId) => {
541
- const node = nodes[nodeId];
542
- return node && areDependenciesSatisfied(node, completedNodeIdSet);
543
- });
544
- if (nodesToExecute.length === 0) {
545
- 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) {
546
603
  await workflowService.updateRunStatus(runId, 'completed');
547
604
  }
548
605
  return;
549
606
  }
550
- await Promise.all(nodesToExecute.map(async (nodeId) => {
551
- const node = nodes[nodeId];
607
+ let executed = 0;
608
+ await Promise.all(plan.toFire.map(async (fire) => {
609
+ const node = nodes[fire.logical];
552
610
  if (!node?.rpcName)
553
611
  return;
554
612
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter((id) => !IGNORED_REFS.has(id));
555
613
  const fetchedResults = await workflowService.getNodeResults(runId, referencedNodeIds);
556
614
  const nodeResults = { trigger: triggerInput, ...fetchedResults };
557
615
  const resolvedInput = resolveSerializedInput(node.input, nodeResults);
558
- 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);
559
618
  }));
619
+ // Nothing executable fired (e.g. nodes without an rpcName) → can't progress.
620
+ if (executed === 0)
621
+ return;
560
622
  }
561
623
  }
562
624
  export async function runWorkflowGraph(workflowService, graphName, triggerInput, rpcService, inline, startNode, wire, overrideMeta) {
@@ -600,7 +662,7 @@ export async function runWorkflowGraph(workflowService, graphName, triggerInput,
600
662
  const resolvedInput = node.input && Object.keys(node.input).length > 0
601
663
  ? resolveSerializedInput(node.input, triggerNodeResults)
602
664
  : triggerInput;
603
- await executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, resolvedInput, nodes);
665
+ await executeGraphNodeInline(workflowService, rpcService, runId, graphName, nodeId, nodeId, resolvedInput, nodes);
604
666
  }));
605
667
  await continueGraphInline(workflowService, rpcService, runId, graphName, nodes, triggerInput, entryNodes);
606
668
  }
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Workflow module exports
3
3
  */
4
- export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowNotFoundError, WorkflowRunNotFoundError, } from './pikku-workflow-service.js';
4
+ export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
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';
5
8
  export { addWorkflow } from './dsl/workflow-runner.js';
6
9
  export { template, type TemplateString } from './graph/template.js';
7
10
  export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Workflow module exports
3
3
  */
4
- export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowNotFoundError, WorkflowRunNotFoundError, } from './pikku-workflow-service.js';
4
+ export { PikkuWorkflowService, WorkflowCancelledException, WorkflowSuspendedException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunNotFoundError, DEFAULT_STEP_RETRIES, } from './pikku-workflow-service.js';
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';
5
8
  // Internal registration functions (used by generated code)
6
9
  export { addWorkflow } from './dsl/workflow-runner.js';
7
10
  // Graph helpers (template, pikkuWorkflowGraph)
@@ -1,7 +1,17 @@
1
1
  import type { SerializedError } from '../../types/core.types.js';
2
- import type { PikkuWorkflowWire, StepState, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
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';
6
+ import type { JobOptions } from '../queue/queue.types.js';
7
+ /**
8
+ * Default number of retries for a workflow step when none is specified. The
9
+ * workflow — not the queue — owns retry policy; a step inherits this unless it
10
+ * sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
11
+ * so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
12
+ * out by default; safe because every step gets a stable `invocationId` to dedupe on.
13
+ */
14
+ export declare const DEFAULT_STEP_RETRIES = 5;
5
15
  /**
6
16
  * Exception thrown when workflow needs to pause for async step
7
17
  */
@@ -26,6 +36,21 @@ export declare class WorkflowSuspendedException extends Error {
26
36
  readonly reason: string;
27
37
  constructor(runId: string, reason: string);
28
38
  }
39
+ /**
40
+ * Thrown when a step (or the orchestrator) could not be enqueued — the queue
41
+ * itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
42
+ * This is transient infrastructure failure: the run is left untouched (the step
43
+ * stays `pending`, the run stays running) and the orchestrator job is rethrown
44
+ * so the queue redelivers it and the workflow replays from its snapshot. Treat
45
+ * it as non-terminal — never mark the run `failed` for it.
46
+ */
47
+ export declare class WorkflowDispatchException extends Error {
48
+ readonly runId: string;
49
+ readonly stepName: string;
50
+ constructor(runId: string, stepName: string, options?: {
51
+ cause?: unknown;
52
+ });
53
+ }
29
54
  /**
30
55
  * Error class for workflow not found
31
56
  */
@@ -99,6 +124,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
99
124
  * Used by the public API — the console addon provides the full verbose view.
100
125
  */
101
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>;
102
141
  /**
103
142
  * Get workflow run history (all step attempts in chronological order)
104
143
  * @param runId - Run ID
@@ -124,8 +163,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
124
163
  * @param stepOptions - Step options (retries, retryDelay)
125
164
  * @returns Step state with generated stepId
126
165
  */
127
- insertStepState(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions): Promise<StepState>;
128
- protected abstract insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions): Promise<StepState>;
166
+ insertStepState(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
167
+ protected abstract insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
129
168
  /**
130
169
  * Get step state by cache key (read-only)
131
170
  * @param runId - Run ID
@@ -216,6 +255,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
216
255
  * @returns Node IDs that don't have a step yet
217
256
  */
218
257
  abstract getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
258
+ /**
259
+ * List every step instance of a run (any status) with its predecessor.
260
+ * Drives bounded graph revisits: the runner counts instances per logical node
261
+ * and treats each `fromStepName → node` as a once-fired transition.
262
+ * @param runId - Run ID
263
+ */
264
+ abstract getStepInstances(runId: string): Promise<Array<{
265
+ stepName: string;
266
+ status: StepStatus;
267
+ fromStepName?: string;
268
+ }>>;
219
269
  /**
220
270
  * Get results for specific nodes
221
271
  * @param runId - Run ID
@@ -262,7 +312,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
262
312
  * @param runId - Run ID
263
313
  */
264
314
  resumeWorkflow(runId: string, workflowName?: string): Promise<void>;
265
- queueStepWorker(runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions): Promise<void>;
315
+ /**
316
+ * Resolve a step's retry policy into queue job options. The workflow is the
317
+ * sole source of truth for retries: an explicitly-set `retries` (including 0)
318
+ * is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
319
+ * and we ALWAYS pass `attempts` so the queue can never fall back to its own
320
+ * default — which would re-run a step the workflow said not to retry. Backoff
321
+ * defaults to exponential whenever there's at least one retry, so retries ride
322
+ * out a transient outage instead of firing instantly.
323
+ */
324
+ protected resolveStepJobOptions(stepOptions?: WorkflowStepOptions): JobOptions;
325
+ queueStepWorker(runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<void>;
266
326
  /**
267
327
  * Execute a workflow sleep step completion
268
328
  * Sets the step result to null and resumes the workflow
@@ -289,7 +349,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
289
349
  * @returns true if dispatch was async (caller should pause), false to fall
290
350
  * through to the inline execution path.
291
351
  */
292
- protected dispatchStep(runId: string, stepName: string, rpcName: string, data: unknown, stepOptions?: WorkflowStepOptions): Promise<boolean>;
352
+ protected dispatchStep(runId: string, stepName: string, rpcName: string, data: unknown, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<boolean>;
293
353
  /**
294
354
  * Schedule a workflow sleep wakeup at the given duration.
295
355
  *
@@ -317,7 +377,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
317
377
  pollIntervalMs?: number;
318
378
  wire?: WorkflowRunWire;
319
379
  }): Promise<any>;
380
+ private stepOrdinals;
381
+ private stepLineage;
382
+ private resetStepOrdinals;
383
+ /** The step the DSL walk last reached (the predecessor for the next step). */
384
+ private lastStepName;
385
+ /**
386
+ * Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
387
+ * bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
388
+ * repeats — so the same literal step name can be invoked multiple times without
389
+ * the rows clobbering. Deterministic given a deterministic DSL body.
390
+ */
391
+ private nextStepKey;
320
392
  runWorkflowJob(runId: string, rpcService: any): Promise<void>;
393
+ private runWorkflowJobInner;
321
394
  private onChildWorkflowCompleted;
322
395
  private onChildWorkflowFailed;
323
396
  private runVersionMismatchFallback;
@@ -332,6 +405,23 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
332
405
  */
333
406
  orchestrateWorkflow(runId: string, rpcService: any): Promise<void>;
334
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;
335
425
  private rpcStep;
336
426
  private inlineStep;
337
427
  private sleepStep;