@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
@@ -2,6 +2,7 @@ import {
2
2
  type PikkuWorkflowService,
3
3
  WorkflowAsyncException,
4
4
  WorkflowSuspendedException,
5
+ DEFAULT_STEP_RETRIES,
5
6
  } from '../pikku-workflow-service.js'
6
7
  import type { GraphWireState, PikkuGraphWire } from './workflow-graph.types.js'
7
8
  import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
@@ -28,6 +29,13 @@ function buildTemplateRegex(nodeId: string): RegExp | null {
28
29
  return new RegExp(`^${escaped}$`)
29
30
  }
30
31
 
32
+ /** Strip a trailing revisit ordinal (`node#2` → `node`); leaves other names as-is. */
33
+ function stripInstanceOrdinal(name: string): string {
34
+ const hash = name.lastIndexOf('#')
35
+ if (hash <= 0) return name
36
+ return /^\d+$/.test(name.slice(hash + 1)) ? name.slice(0, hash) : name
37
+ }
38
+
31
39
  function remapStepNamesToNodeIds(
32
40
  stepNames: string[],
33
41
  nodes: Record<string, any>,
@@ -38,12 +46,14 @@ function remapStepNamesToNodeIds(
38
46
  const regex = buildTemplateRegex(nodeId)
39
47
  if (regex) templatePatterns.set(nodeId, regex)
40
48
  }
41
- if (templatePatterns.size === 0) return stepNames
42
49
  return stepNames.map((name) => {
43
50
  if (nodes[name]) return name
51
+ // Revisit instance (`node#N`) maps to its logical node.
52
+ const base = stripInstanceOrdinal(name)
53
+ if (base !== name && nodes[base]) return base
44
54
  const matches: string[] = []
45
55
  for (const [nodeId, regex] of templatePatterns) {
46
- if (regex.test(name)) matches.push(nodeId)
56
+ if (regex.test(base)) matches.push(nodeId)
47
57
  }
48
58
  if (matches.length > 1) {
49
59
  throw new Error(
@@ -57,6 +67,122 @@ function remapStepNamesToNodeIds(
57
67
  })
58
68
  }
59
69
 
70
+ const ENTRY_FROM = '__entry__'
71
+
72
+ /** Whether `target` can reach `source` over `next` edges — i.e. an edge
73
+ * source→target closes a cycle (a back-edge), vs a plain forward edge. */
74
+ function closesCycle(
75
+ source: string,
76
+ target: string,
77
+ nodes: Record<string, any>
78
+ ): boolean {
79
+ const seen = new Set<string>()
80
+ const stack = [target]
81
+ while (stack.length) {
82
+ const cur = stack.pop()!
83
+ if (cur === source) return true
84
+ if (seen.has(cur)) continue
85
+ seen.add(cur)
86
+ for (const next of normalizeNodeTargets(nodes[cur]?.next)) stack.push(next)
87
+ }
88
+ return false
89
+ }
90
+
91
+ /**
92
+ * Decide which next steps to fire this tick. Two kinds of edge:
93
+ * - forward edge → node-once: fire the target only if it has no instance yet,
94
+ * so converging edges (joins) collapse to a single run (unchanged behavior).
95
+ * - back-edge (target can reach the source, closing a cycle) → revisit: fire a
96
+ * fresh ordinal instance (`target#1`, …), edge-once on `from → target` so it
97
+ * doesn't re-fire every tick. Cycles terminate when branch routing stops
98
+ * looping back; a node always records the predecessor it was reached from.
99
+ */
100
+ function planGraphTransitions(
101
+ nodes: Record<string, any>,
102
+ instances: Array<{ stepName: string; status: string; fromStepName?: string }>,
103
+ branchByStep: Record<string, string>,
104
+ entryNodeIds: string[],
105
+ graphName: string
106
+ ): {
107
+ toFire: Array<{ logical: string; instanceKey: string; fromStepName?: string }>
108
+ hasInFlight: boolean
109
+ blockedWaiting: boolean
110
+ } {
111
+ const toLogical = (name: string) =>
112
+ remapStepNamesToNodeIds([name], nodes, graphName)[0]!
113
+
114
+ const countByLogical: Record<string, number> = {}
115
+ const consumed = new Set<string>()
116
+ for (const inst of instances) {
117
+ const logical = toLogical(inst.stepName)
118
+ countByLogical[logical] = (countByLogical[logical] ?? 0) + 1
119
+ consumed.add(`${inst.fromStepName ?? ENTRY_FROM}->${logical}`)
120
+ }
121
+
122
+ const completed = instances.filter((i) => i.status === 'succeeded')
123
+ const completedLogical = new Set(completed.map((i) => toLogical(i.stepName)))
124
+
125
+ // Available edges: entry edges + each completed instance's resolved `next`.
126
+ const edges: Array<{
127
+ from?: string
128
+ fromKey: string
129
+ fromLogical?: string
130
+ target: string
131
+ }> = []
132
+ for (const entryId of entryNodeIds) {
133
+ edges.push({ fromKey: ENTRY_FROM, target: entryId })
134
+ }
135
+ for (const inst of completed) {
136
+ const fromLogical = toLogical(inst.stepName)
137
+ const node = nodes[fromLogical]
138
+ if (!node?.next) continue
139
+ for (const target of resolveNextFromConfig(
140
+ node.next,
141
+ branchByStep[inst.stepName]
142
+ )) {
143
+ edges.push({ from: inst.stepName, fromKey: inst.stepName, fromLogical, target })
144
+ }
145
+ }
146
+
147
+ const toFire: Array<{
148
+ logical: string
149
+ instanceKey: string
150
+ fromStepName?: string
151
+ }> = []
152
+ let blockedWaiting = false
153
+ for (const edge of edges) {
154
+ const target = edge.target
155
+ const edgeKey = `${edge.fromKey}->${target}`
156
+ if (consumed.has(edgeKey)) continue
157
+ const visits = countByLogical[target] ?? 0
158
+ const isBackEdge =
159
+ edge.fromLogical !== undefined &&
160
+ closesCycle(edge.fromLogical, target, nodes)
161
+ // Forward edge into an already-started node = a join; node-once.
162
+ if (!isBackEdge && visits > 0) {
163
+ consumed.add(edgeKey)
164
+ continue
165
+ }
166
+ if (!areDependenciesSatisfied(nodes[target] ?? {}, completedLogical)) {
167
+ blockedWaiting = true
168
+ continue
169
+ }
170
+ toFire.push({
171
+ logical: target,
172
+ instanceKey: visits === 0 ? target : `${target}#${visits}`,
173
+ fromStepName: edge.from,
174
+ })
175
+ countByLogical[target] = visits + 1
176
+ consumed.add(edgeKey)
177
+ }
178
+
179
+ return {
180
+ toFire,
181
+ hasInFlight: instances.some((i) => i.status !== 'succeeded'),
182
+ blockedWaiting,
183
+ }
184
+ }
185
+
60
186
  function remapBranchKeys(
61
187
  branchKeys: Record<string, string>,
62
188
  nodes: Record<string, any>,
@@ -325,10 +451,13 @@ async function queueGraphNode(
325
451
  nodeId: string,
326
452
  rpcName: string,
327
453
  input: any,
328
- nodeConfig?: { retries?: number; retryDelay?: string | number }
454
+ nodeConfig?: { retries?: number; retryDelay?: string | number },
455
+ fromStepName?: string
329
456
  ): Promise<void> {
457
+ // Default to the workflow-wide retry policy when the node sets none, so the
458
+ // persisted step retries match the queue `attempts` (see resolveStepJobOptions).
330
459
  const stepOptions = {
331
- retries: nodeConfig?.retries ?? 0,
460
+ retries: nodeConfig?.retries ?? DEFAULT_STEP_RETRIES,
332
461
  retryDelay: nodeConfig?.retryDelay,
333
462
  }
334
463
  await workflowService.insertStepState(
@@ -336,14 +465,16 @@ async function queueGraphNode(
336
465
  nodeId,
337
466
  rpcName,
338
467
  input,
339
- stepOptions
468
+ stepOptions,
469
+ fromStepName
340
470
  )
341
471
  await workflowService.queueStepWorker(
342
472
  runId,
343
473
  nodeId,
344
474
  rpcName,
345
475
  input,
346
- stepOptions
476
+ stepOptions,
477
+ fromStepName
347
478
  )
348
479
  }
349
480
 
@@ -364,16 +495,13 @@ export async function continueGraph(
364
495
  const {
365
496
  completedNodeIds: rawCompleted,
366
497
  failedNodeIds: rawFailed,
367
- branchKeys: rawBranch,
498
+ branchKeys: branchByStep,
368
499
  } = await workflowService.getCompletedGraphState(runId)
369
- const completedNodeIds = remapStepNamesToNodeIds(
370
- rawCompleted,
371
- nodes,
372
- graphName
373
- )
374
- const completedNodeIdSet = new Set(completedNodeIds)
500
+ // Validate step/branch names map to unambiguous nodes (planning keys
501
+ // physically; these calls only surface ambiguous template configs).
502
+ remapStepNamesToNodeIds(rawCompleted, nodes, graphName)
503
+ remapBranchKeys(branchByStep, nodes, graphName)
375
504
  const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName)
376
- const branchKeys = remapBranchKeys(rawBranch, nodes, graphName)
377
505
 
378
506
  if (failedNodeIds.length > 0) {
379
507
  const failedNode = failedNodeIds[0]!
@@ -390,44 +518,18 @@ export async function continueGraph(
390
518
  return
391
519
  }
392
520
 
393
- const candidateNodes = new Set<string>()
394
-
395
- for (const nodeId of completedNodeIds) {
396
- const node = nodes[nodeId]
397
- if (!node?.next) continue
398
-
399
- const nextNodes = resolveNextFromConfig(node.next, branchKeys[nodeId])
400
- for (const nextNode of nextNodes) {
401
- candidateNodes.add(nextNode)
402
- }
403
- }
404
-
405
- for (const entryId of meta.entryNodeIds ?? []) {
406
- candidateNodes.add(entryId)
407
- }
408
-
409
- if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
410
- await workflowService.updateRunStatus(runId, 'completed')
411
- return
412
- }
413
-
414
- const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
415
- ...candidateNodes,
416
- ])
417
-
418
- const nodesToQueue = unstartedNodes.filter((nodeId) => {
419
- const node = nodes[nodeId]
420
- return node && areDependenciesSatisfied(node, completedNodeIdSet)
421
- })
521
+ const instances = await workflowService.getStepInstances(runId)
522
+ const plan = planGraphTransitions(
523
+ nodes,
524
+ instances,
525
+ branchByStep,
526
+ meta.entryNodeIds ?? [],
527
+ graphName
528
+ )
422
529
 
423
- if (nodesToQueue.length === 0) {
424
- const allRpcNodes = Object.entries(nodes)
425
- .filter(([_, n]) => n.rpcName)
426
- .map(([id]) => id)
427
- const allRpcCompleted = allRpcNodes.every((id) =>
428
- completedNodeIdSet.has(id)
429
- )
430
- if (allRpcCompleted) {
530
+ if (plan.toFire.length === 0) {
531
+ // Nothing left to fire and nothing running/blocked → the run is done.
532
+ if (!plan.hasInFlight && !plan.blockedWaiting) {
431
533
  await workflowService.updateRunStatus(runId, 'completed')
432
534
  }
433
535
  return
@@ -436,8 +538,8 @@ export async function continueGraph(
436
538
  const run = await workflowService.getRun(runId)
437
539
  const triggerInput = run?.input
438
540
 
439
- for (const nodeId of nodesToQueue) {
440
- const node = nodes[nodeId]
541
+ for (const fire of plan.toFire) {
542
+ const node = nodes[fire.logical]
441
543
  if (!node?.rpcName) continue
442
544
 
443
545
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter(
@@ -447,7 +549,6 @@ export async function continueGraph(
447
549
  runId,
448
550
  referencedNodeIds
449
551
  )
450
-
451
552
  const nodeResults = { trigger: triggerInput, ...fetchedResults }
452
553
  const resolvedInput = resolveSerializedInput(node.input, nodeResults)
453
554
 
@@ -455,22 +556,30 @@ export async function continueGraph(
455
556
  workflowService,
456
557
  runId,
457
558
  graphName,
458
- nodeId,
559
+ fire.instanceKey,
459
560
  node.rpcName,
460
561
  resolvedInput,
461
- node
562
+ node,
563
+ fire.fromStepName
462
564
  )
463
565
  }
464
566
  }
465
567
 
466
- export async function executeGraphStep(
568
+ /**
569
+ * Invoke a graph node's RPC with the graph + workflow wires, capturing any
570
+ * branch the node selects and persisting it. Shared by the queued
571
+ * (executeGraphStep) and inline (executeGraphNodeInline) executors so both build
572
+ * the wire and record the branch identically — only their child-workflow and
573
+ * onError handling differs around this call.
574
+ */
575
+ async function invokeGraphNodeRpc(
467
576
  workflowService: PikkuWorkflowService,
468
577
  rpcService: any,
469
578
  runId: string,
470
579
  stepId: string,
471
580
  nodeId: string,
472
581
  rpcName: string,
473
- data: any,
582
+ input: any,
474
583
  graphName: string
475
584
  ): Promise<any> {
476
585
  const wireState: GraphWireState = {}
@@ -486,6 +595,28 @@ export async function executeGraphStep(
486
595
  getState: () => workflowService.getRunState(runId),
487
596
  }
488
597
 
598
+ const result = await rpcService.rpcWithWire(rpcName, input, {
599
+ graph: graphWire,
600
+ workflow: workflowService.createWorkflowWire(graphName, runId, rpcService),
601
+ })
602
+
603
+ if (wireState.branchKey) {
604
+ await workflowService.setBranchTaken(stepId, wireState.branchKey)
605
+ }
606
+
607
+ return result
608
+ }
609
+
610
+ export async function executeGraphStep(
611
+ workflowService: PikkuWorkflowService,
612
+ rpcService: any,
613
+ runId: string,
614
+ stepId: string,
615
+ nodeId: string,
616
+ rpcName: string,
617
+ data: any,
618
+ graphName: string
619
+ ): Promise<any> {
489
620
  try {
490
621
  let result: any
491
622
 
@@ -520,18 +651,16 @@ export async function executeGraphStep(
520
651
  throw new ChildWorkflowStartedException(runId, stepId, childRunId)
521
652
  }
522
653
  } else {
523
- result = await rpcService.rpcWithWire(rpcName, data, {
524
- graph: graphWire,
525
- workflow: workflowService.createWorkflowWire(
526
- graphName,
527
- runId,
528
- rpcService
529
- ),
530
- })
531
- }
532
-
533
- if (wireState.branchKey) {
534
- await workflowService.setBranchTaken(stepId, wireState.branchKey)
654
+ result = await invokeGraphNodeRpc(
655
+ workflowService,
656
+ rpcService,
657
+ runId,
658
+ stepId,
659
+ nodeId,
660
+ rpcName,
661
+ data,
662
+ graphName
663
+ )
535
664
  }
536
665
 
537
666
  return result
@@ -603,37 +732,30 @@ async function executeGraphNodeInline(
603
732
  runId: string,
604
733
  graphName: string,
605
734
  nodeId: string,
735
+ instanceKey: string,
606
736
  input: any,
607
- nodes: Record<string, any>
737
+ nodes: Record<string, any>,
738
+ fromStepName?: string
608
739
  ): Promise<void> {
609
740
  const node = nodes[nodeId]
610
741
  if (!node) return
611
742
 
612
743
  const rpcName = node.rpcName
613
744
 
745
+ // Persist under the physical instance key (node, node#1 … for revisits) and
746
+ // record the predecessor — same as the queued path (queueGraphNode), so an
747
+ // inline graph run stores identical step rows + provenance.
614
748
  const stepState = await workflowService.insertStepState(
615
749
  runId,
616
- nodeId,
750
+ instanceKey,
617
751
  rpcName,
618
752
  input,
619
- { retries: node.retries ?? 0, retryDelay: node.retryDelay }
753
+ { retries: node.retries ?? 0, retryDelay: node.retryDelay },
754
+ fromStepName
620
755
  )
621
756
 
622
757
  await workflowService.setStepRunning(stepState.stepId)
623
758
 
624
- const wireState: GraphWireState = {}
625
- const graphWire: PikkuGraphWire = {
626
- runId,
627
- graphName,
628
- nodeId,
629
- branch: (key: string) => {
630
- wireState.branchKey = key
631
- },
632
- setState: (name: string, value: unknown) =>
633
- workflowService.updateRunState(runId, name, value),
634
- getState: () => workflowService.getRunState(runId),
635
- }
636
-
637
759
  try {
638
760
  let result: any
639
761
 
@@ -662,20 +784,15 @@ async function executeGraphNodeInline(
662
784
  }
663
785
  result = childRun?.output
664
786
  } else {
665
- result = await rpcService.rpcWithWire(rpcName, input, {
666
- graph: graphWire,
667
- workflow: workflowService.createWorkflowWire(
668
- graphName,
669
- runId,
670
- rpcService
671
- ),
672
- })
673
- }
674
-
675
- if (wireState.branchKey) {
676
- await workflowService.setBranchTaken(
787
+ result = await invokeGraphNodeRpc(
788
+ workflowService,
789
+ rpcService,
790
+ runId,
677
791
  stepState.stepId,
678
- wireState.branchKey
792
+ nodeId,
793
+ rpcName,
794
+ input,
795
+ graphName
679
796
  )
680
797
  }
681
798
 
@@ -709,8 +826,10 @@ async function executeGraphNodeInline(
709
826
  runId,
710
827
  graphName,
711
828
  errorNodeId,
829
+ errorNodeId,
712
830
  { error: { message: (error as Error).message } },
713
- nodes
831
+ nodes,
832
+ nodeId
714
833
  )
715
834
  )
716
835
  )
@@ -729,20 +848,22 @@ async function continueGraphInline(
729
848
  triggerInput: any,
730
849
  entryNodeIds: string[]
731
850
  ): Promise<void> {
851
+ // Drive the run to completion in-process using the SAME planner as the queued
852
+ // path (continueGraph): each loop plans the next wave of transitions, executes
853
+ // them inline (vs queueGraphNode dispatch), then re-plans. Sharing the planner
854
+ // gives the inline path joins, cycle revisits and fromStepName provenance
855
+ // identical to the queue — instead of a second, weaker traversal.
732
856
  while (true) {
733
857
  const {
734
- completedNodeIds: rawCompleted,
735
858
  failedNodeIds: rawFailed,
736
- branchKeys: rawBranch,
859
+ branchKeys: branchByStep,
860
+ completedNodeIds: rawCompleted,
737
861
  } = await workflowService.getCompletedGraphState(runId)
738
- const completedNodeIds = remapStepNamesToNodeIds(
739
- rawCompleted,
740
- nodes,
741
- graphName
742
- )
743
- const completedNodeIdSet = new Set(completedNodeIds)
862
+ // Validate step/branch names map to unambiguous nodes (planning keys
863
+ // physically; these calls only surface ambiguous template configs).
864
+ remapStepNamesToNodeIds(rawCompleted, nodes, graphName)
865
+ remapBranchKeys(branchByStep, nodes, graphName)
744
866
  const failedNodeIds = remapStepNamesToNodeIds(rawFailed, nodes, graphName)
745
- const branchKeys = remapBranchKeys(rawBranch, nodes, graphName)
746
867
 
747
868
  if (failedNodeIds.length > 0) {
748
869
  const failedNode = failedNodeIds[0]!
@@ -754,46 +875,31 @@ async function continueGraphInline(
754
875
  return
755
876
  }
756
877
 
757
- const candidateNodes = new Set<string>()
758
-
759
- for (const nodeId of completedNodeIds) {
760
- const node = nodes[nodeId]
761
- if (!node?.next) continue
762
-
763
- const nextNodes = resolveNextFromConfig(node.next, branchKeys[nodeId])
764
- for (const nextNode of nextNodes) {
765
- candidateNodes.add(nextNode)
766
- }
767
- }
768
-
769
- for (const entryId of entryNodeIds) {
770
- candidateNodes.add(entryId)
771
- }
772
-
773
- if (candidateNodes.size === 0 && completedNodeIds.length > 0) {
774
- await workflowService.updateRunStatus(runId, 'completed')
878
+ const run = await workflowService.getRun(runId)
879
+ if (run?.status === 'suspended') {
775
880
  return
776
881
  }
777
882
 
778
- const unstartedNodes = await workflowService.getNodesWithoutSteps(runId, [
779
- ...candidateNodes,
780
- ])
781
-
782
- const nodesToExecute = unstartedNodes.filter((nodeId) => {
783
- const node = nodes[nodeId]
784
- return node && areDependenciesSatisfied(node, completedNodeIdSet)
785
- })
883
+ const instances = await workflowService.getStepInstances(runId)
884
+ const plan = planGraphTransitions(
885
+ nodes,
886
+ instances,
887
+ branchByStep,
888
+ entryNodeIds,
889
+ graphName
890
+ )
786
891
 
787
- if (nodesToExecute.length === 0) {
788
- if (completedNodeIds.length > 0 && unstartedNodes.length === 0) {
892
+ if (plan.toFire.length === 0) {
893
+ if (!plan.hasInFlight && !plan.blockedWaiting) {
789
894
  await workflowService.updateRunStatus(runId, 'completed')
790
895
  }
791
896
  return
792
897
  }
793
898
 
899
+ let executed = 0
794
900
  await Promise.all(
795
- nodesToExecute.map(async (nodeId) => {
796
- const node = nodes[nodeId]
901
+ plan.toFire.map(async (fire) => {
902
+ const node = nodes[fire.logical]
797
903
  if (!node?.rpcName) return
798
904
 
799
905
  const referencedNodeIds = extractReferencedNodeIds(node.input).filter(
@@ -803,21 +909,25 @@ async function continueGraphInline(
803
909
  runId,
804
910
  referencedNodeIds
805
911
  )
806
-
807
912
  const nodeResults = { trigger: triggerInput, ...fetchedResults }
808
913
  const resolvedInput = resolveSerializedInput(node.input, nodeResults)
809
914
 
915
+ executed++
810
916
  await executeGraphNodeInline(
811
917
  workflowService,
812
918
  rpcService,
813
919
  runId,
814
920
  graphName,
815
- nodeId,
921
+ fire.logical,
922
+ fire.instanceKey,
816
923
  resolvedInput,
817
- nodes
924
+ nodes,
925
+ fire.fromStepName
818
926
  )
819
927
  })
820
928
  )
929
+ // Nothing executable fired (e.g. nodes without an rpcName) → can't progress.
930
+ if (executed === 0) return
821
931
  }
822
932
  }
823
933
 
@@ -900,6 +1010,7 @@ export async function runWorkflowGraph(
900
1010
  runId,
901
1011
  graphName,
902
1012
  nodeId,
1013
+ nodeId,
903
1014
  resolvedInput,
904
1015
  nodes
905
1016
  )
@@ -5,9 +5,26 @@ export {
5
5
  PikkuWorkflowService,
6
6
  WorkflowCancelledException,
7
7
  WorkflowSuspendedException,
8
+ WorkflowDispatchException,
8
9
  WorkflowNotFoundError,
9
10
  WorkflowRunNotFoundError,
11
+ DEFAULT_STEP_RETRIES,
10
12
  } from './pikku-workflow-service.js'
13
+ export { deriveInvocationId, uuidv5 } from './workflow-invocation-id.js'
14
+
15
+ // Time-travel: reconstruct run state at any point from durable history
16
+ export {
17
+ buildRunTimeline,
18
+ reconstructStateAt,
19
+ reconstructFinalState,
20
+ } from './run-timeline.js'
21
+ export type {
22
+ RunTimeline,
23
+ RunTimelineEvent,
24
+ ReconstructedRunState,
25
+ ReconstructedStep,
26
+ RunPhase,
27
+ } from './run-timeline.js'
11
28
 
12
29
  // Internal registration functions (used by generated code)
13
30
  export { addWorkflow } from './dsl/workflow-runner.js'