@pikku/core 0.12.37 → 0.12.38

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 (30) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  3. package/dist/services/in-memory-workflow-service.js +17 -1
  4. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  5. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  6. package/dist/wirings/workflow/index.d.ts +2 -1
  7. package/dist/wirings/workflow/index.js +2 -1
  8. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  9. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  10. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  11. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  12. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  13. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  14. package/package.json +2 -1
  15. package/src/dev/hot-reload.test.ts +5 -0
  16. package/src/services/in-memory-workflow-service.ts +25 -1
  17. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  18. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  19. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  20. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  21. package/src/wirings/workflow/index.ts +3 -0
  22. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  23. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  24. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  25. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  26. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  27. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  28. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  29. package/src/wirings/workflow/workflow.types.ts +5 -0
  30. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,6 @@
1
- import { describe, test, afterEach, mock } from 'node:test'
1
+ import { describe, test, afterEach } from 'node:test'
2
+
3
+ const mockFn = <T extends (...args: any[]) => any>(fn: T): T => fn
2
4
  import * as assert from 'node:assert/strict'
3
5
  import { OAuth2Client } from './oauth2-client.js'
4
6
  import type { OAuth2Token, OAuth2AppCredential } from './oauth2.types.js'
@@ -94,7 +96,7 @@ describe('OAuth2Client', () => {
94
96
  APP_CREDS: defaultAppCredential,
95
97
  })
96
98
 
97
- globalThis.fetch = mock.fn(async () =>
99
+ globalThis.fetch = mockFn(async () =>
98
100
  mockFetchResponse({
99
101
  access_token: 'new-access-token',
100
102
  refresh_token: 'new-refresh-token',
@@ -141,7 +143,7 @@ describe('OAuth2Client', () => {
141
143
  APP_CREDS: defaultAppCredential,
142
144
  })
143
145
 
144
- globalThis.fetch = mock.fn(async () =>
146
+ globalThis.fetch = mockFn(async () =>
145
147
  mockFetchResponse({
146
148
  access_token: 'refreshed-token',
147
149
  expires_in: 3600,
@@ -183,7 +185,7 @@ describe('OAuth2Client', () => {
183
185
  APP_CREDS: defaultAppCredential,
184
186
  })
185
187
 
186
- globalThis.fetch = mock.fn(async () =>
188
+ globalThis.fetch = mockFn(async () =>
187
189
  mockFetchResponse({
188
190
  access_token: 'refreshed-due-to-buffer',
189
191
  expires_in: 3600,
@@ -215,7 +217,7 @@ describe('OAuth2Client', () => {
215
217
 
216
218
  let capturedRequest: { url: string; options: RequestInit } | null = null
217
219
 
218
- globalThis.fetch = mock.fn(
220
+ globalThis.fetch = mockFn(
219
221
  async (url: RequestInfo | URL, options?: RequestInit) => {
220
222
  capturedRequest = { url: url.toString(), options: options! }
221
223
  return mockFetchResponse({
@@ -259,7 +261,7 @@ describe('OAuth2Client', () => {
259
261
  })
260
262
 
261
263
  let fetchCallCount = 0
262
- globalThis.fetch = mock.fn(async () => {
264
+ globalThis.fetch = mockFn(async () => {
263
265
  fetchCallCount++
264
266
  return mockFetchResponse({
265
267
  access_token: 'new-cached-token',
@@ -293,7 +295,7 @@ describe('OAuth2Client', () => {
293
295
  APP_CREDS: defaultAppCredential,
294
296
  })
295
297
 
296
- globalThis.fetch = mock.fn(async () =>
298
+ globalThis.fetch = mockFn(async () =>
297
299
  mockFetchResponse({ error: 'invalid_grant' }, 400, false)
298
300
  )
299
301
 
@@ -339,7 +341,7 @@ describe('OAuth2Client', () => {
339
341
  APP_CREDS: defaultAppCredential,
340
342
  })
341
343
 
342
- globalThis.fetch = mock.fn(async () =>
344
+ globalThis.fetch = mockFn(async () =>
343
345
  mockFetchResponse({
344
346
  access_token: 'new-persisted-token',
345
347
  refresh_token: 'new-refresh-token',
@@ -374,7 +376,7 @@ describe('OAuth2Client', () => {
374
376
  })
375
377
 
376
378
  let fetchCallCount = 0
377
- globalThis.fetch = mock.fn(async () => {
379
+ globalThis.fetch = mockFn(async () => {
378
380
  fetchCallCount++
379
381
  // Simulate network delay
380
382
  await new Promise((resolve) => setTimeout(resolve, 50))
@@ -417,7 +419,7 @@ describe('OAuth2Client', () => {
417
419
  })
418
420
 
419
421
  // Response doesn't include refresh_token
420
- globalThis.fetch = mock.fn(async () =>
422
+ globalThis.fetch = mockFn(async () =>
421
423
  mockFetchResponse({
422
424
  access_token: 'new-token',
423
425
  expires_in: 3600,
@@ -453,7 +455,7 @@ describe('OAuth2Client', () => {
453
455
 
454
456
  let capturedHeaders: Record<string, string> | null = null
455
457
 
456
- globalThis.fetch = mock.fn(
458
+ globalThis.fetch = mockFn(
457
459
  async (_url: RequestInfo | URL, options?: RequestInit) => {
458
460
  capturedHeaders = options?.headers as Record<string, string>
459
461
  return mockFetchResponse({ data: 'test' })
@@ -482,7 +484,7 @@ describe('OAuth2Client', () => {
482
484
  })
483
485
 
484
486
  let requestCount = 0
485
- globalThis.fetch = mock.fn(
487
+ globalThis.fetch = mockFn(
486
488
  async (url: RequestInfo | URL, options?: RequestInit) => {
487
489
  requestCount++
488
490
  const urlStr = url.toString()
@@ -529,7 +531,7 @@ describe('OAuth2Client', () => {
529
531
  })
530
532
 
531
533
  let apiRequestCount = 0
532
- globalThis.fetch = mock.fn(async (url: RequestInfo | URL) => {
534
+ globalThis.fetch = mockFn(async (url: RequestInfo | URL) => {
533
535
  const urlStr = url.toString()
534
536
 
535
537
  if (urlStr === 'https://example.com/oauth/token') {
@@ -565,7 +567,7 @@ describe('OAuth2Client', () => {
565
567
  APP_CREDS: defaultAppCredential,
566
568
  })
567
569
 
568
- globalThis.fetch = mock.fn(async () =>
570
+ globalThis.fetch = mockFn(async () =>
569
571
  mockFetchResponse({ error: 'forbidden' }, 403, false)
570
572
  )
571
573
 
@@ -589,7 +591,7 @@ describe('OAuth2Client', () => {
589
591
 
590
592
  let capturedHeaders: Record<string, string> | null = null
591
593
 
592
- globalThis.fetch = mock.fn(
594
+ globalThis.fetch = mockFn(
593
595
  async (_url: RequestInfo | URL, options?: RequestInit) => {
594
596
  capturedHeaders = options?.headers as Record<string, string>
595
597
  return mockFetchResponse({ data: 'test' })
@@ -704,7 +706,7 @@ describe('OAuth2Client', () => {
704
706
 
705
707
  let capturedRequest: { url: string; options: RequestInit } | null = null
706
708
 
707
- globalThis.fetch = mock.fn(
709
+ globalThis.fetch = mockFn(
708
710
  async (url: RequestInfo | URL, options?: RequestInit) => {
709
711
  capturedRequest = { url: url.toString(), options: options! }
710
712
  return mockFetchResponse({
@@ -738,7 +740,7 @@ describe('OAuth2Client', () => {
738
740
  })
739
741
 
740
742
  let fetchCallCount = 0
741
- globalThis.fetch = mock.fn(async () => {
743
+ globalThis.fetch = mockFn(async () => {
742
744
  fetchCallCount++
743
745
  return mockFetchResponse({
744
746
  access_token: 'new-token',
@@ -767,7 +769,7 @@ describe('OAuth2Client', () => {
767
769
  APP_CREDS: defaultAppCredential,
768
770
  })
769
771
 
770
- globalThis.fetch = mock.fn(async () =>
772
+ globalThis.fetch = mockFn(async () =>
771
773
  mockFetchResponse({ error: 'invalid_code' }, 400, false)
772
774
  )
773
775
 
@@ -785,7 +787,7 @@ describe('OAuth2Client', () => {
785
787
  APP_CREDS: defaultAppCredential,
786
788
  })
787
789
 
788
- globalThis.fetch = mock.fn(async () =>
790
+ globalThis.fetch = mockFn(async () =>
789
791
  mockFetchResponse({
790
792
  access_token: 'token-only',
791
793
  // No refresh_token, expires_in, or scope
@@ -813,7 +815,7 @@ describe('OAuth2Client', () => {
813
815
  })
814
816
 
815
817
  // Response without access_token
816
- globalThis.fetch = mock.fn(async () =>
818
+ globalThis.fetch = mockFn(async () =>
817
819
  mockFetchResponse({
818
820
  refresh_token: 'refresh-only',
819
821
  expires_in: 3600,
@@ -835,7 +837,7 @@ describe('OAuth2Client', () => {
835
837
  })
836
838
 
837
839
  // Response with non-string access_token
838
- globalThis.fetch = mock.fn(async () =>
840
+ globalThis.fetch = mockFn(async () =>
839
841
  mockFetchResponse({
840
842
  access_token: 12345, // Number instead of string
841
843
  expires_in: 3600,
@@ -865,7 +867,7 @@ describe('OAuth2Client', () => {
865
867
  })
866
868
 
867
869
  // Error response with sensitive info
868
- globalThis.fetch = mock.fn(async () =>
870
+ globalThis.fetch = mockFn(async () =>
869
871
  mockFetchResponse(
870
872
  {
871
873
  error: 'invalid_grant',
@@ -910,7 +912,7 @@ describe('OAuth2Client', () => {
910
912
  // Instead, we verify that the signal is being passed
911
913
  // by checking that an abort signal is present in the fetch call
912
914
  let signalPassed = false
913
- globalThis.fetch = mock.fn(
915
+ globalThis.fetch = mockFn(
914
916
  async (_url: RequestInfo | URL, options?: RequestInit) => {
915
917
  signalPassed = options?.signal instanceof AbortSignal
916
918
  return mockFetchResponse({
@@ -295,10 +295,28 @@ export type WorkflowStepMeta =
295
295
  export interface WorkflowStepWire {
296
296
  /** The workflow run ID */
297
297
  runId: string
298
- /** The unique step ID */
298
+ /**
299
+ * The step row ID. Whether it stays the same or is minted fresh per attempt is
300
+ * STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
301
+ * the row) — do NOT use it as a dedupe key. Use `invocationId`.
302
+ */
299
303
  stepId: string
304
+ /**
305
+ * Stable identity of this step invocation — the idempotency / dedupe key.
306
+ * Identical across every retry of the same call (derived from runId + step
307
+ * name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
308
+ * or pass it as an external idempotency key and have retries collapse onto the
309
+ * first attempt.
310
+ */
311
+ invocationId: string
300
312
  /** Current attempt number (1-indexed, increments on retry) */
301
313
  attemptCount: number
314
+ /**
315
+ * Invocation ID of the predecessor step this one was reached from (the walked
316
+ * transition/edge). Undefined for entry steps. Lets a step know its origin —
317
+ * e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
318
+ */
319
+ fromInvocationId?: string
302
320
  }
303
321
 
304
322
  /**
@@ -10,6 +10,7 @@ import {
10
10
  import type { WorkflowRuntimeMeta } from '../workflow.types.js'
11
11
  import { pikkuState } from '../../../pikku-state.js'
12
12
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js'
13
+ import { DEFAULT_STEP_RETRIES } from '../pikku-workflow-service.js'
13
14
 
14
15
  describe('graph-runner bugs', () => {
15
16
  test('continueGraph should NOT mark workflow completed while nodes are still running', async () => {
@@ -240,6 +241,84 @@ describe('graph-runner bugs', () => {
240
241
  assert.deepEqual(queuedNodes, ['c'])
241
242
  })
242
243
 
244
+ test('continueGraph revisits a cyclic node and records the walked path via fromStepName', async () => {
245
+ const ws = new InMemoryWorkflowService()
246
+ const queued: Array<{ stepName: string; fromStepName?: string }> = []
247
+
248
+ pikkuState(null, 'package', 'singletonServices', {
249
+ queueService: {
250
+ add: async (_queueName: string, data: any) => {
251
+ if (data?.stepName) {
252
+ queued.push({
253
+ stepName: data.stepName,
254
+ fromStepName: data.fromStepName,
255
+ })
256
+ }
257
+ },
258
+ },
259
+ } as any)
260
+
261
+ // start → a, where a loops back through b once, then exits to c:
262
+ // start → a --retry--> b → a --done--> c
263
+ const meta: WorkflowRuntimeMeta = {
264
+ name: 'testCyclicGraph',
265
+ pikkuFuncId: 'testCyclicGraph',
266
+ source: 'graph',
267
+ entryNodeIds: ['start'],
268
+ graphHash: 'cyclic-hash',
269
+ nodes: {
270
+ start: { nodeId: 'start', rpcName: 'doStart', next: 'a' },
271
+ a: { nodeId: 'a', rpcName: 'doA', next: { retry: 'b', done: 'c' } },
272
+ b: { nodeId: 'b', rpcName: 'doB', next: 'a' },
273
+ c: { nodeId: 'c', rpcName: 'doC' },
274
+ },
275
+ }
276
+
277
+ const runId = await ws.createRun('testCyclicGraph', {}, false, 'cyclic-hash', {
278
+ type: 'test',
279
+ })
280
+
281
+ // Succeed a queued step (taking an optional branch), then advance the graph.
282
+ const advance = async (stepName: string, branch?: string) => {
283
+ const step = await ws.getStepState(runId, stepName)
284
+ await ws.setStepRunning(step.stepId)
285
+ if (branch) await ws.setBranchTaken(step.stepId, branch)
286
+ await ws.setStepResult(step.stepId, { ok: true })
287
+ await continueGraph(ws, runId, 'testCyclicGraph', meta)
288
+ }
289
+
290
+ await continueGraph(ws, runId, 'testCyclicGraph', meta) // fire entry
291
+ await advance('start')
292
+ await advance('a', 'retry') // a → b (b reaches a, but first visit ⇒ bare 'b')
293
+ await advance('b') // b → a revisit ⇒ a#1
294
+ await advance('a#1', 'done') // a#1 → c (forward edge, terminal)
295
+ await advance('c')
296
+
297
+ // Each step records the predecessor it was reached from; the cyclic
298
+ // revisit is a fresh ordinal instance (a#1) with its own provenance.
299
+ assert.deepEqual(queued, [
300
+ { stepName: 'start', fromStepName: undefined },
301
+ { stepName: 'a', fromStepName: 'start' },
302
+ { stepName: 'b', fromStepName: 'a' },
303
+ { stepName: 'a#1', fromStepName: 'b' },
304
+ { stepName: 'c', fromStepName: 'a#1' },
305
+ ])
306
+
307
+ const run = await ws.getRun(runId)
308
+ assert.equal(run?.status, 'completed')
309
+
310
+ // Reconstruct the walked path purely from the fromStepName chain.
311
+ const instances = await ws.getStepInstances(runId)
312
+ const from = new Map(instances.map((i) => [i.stepName, i.fromStepName]))
313
+ const path: string[] = []
314
+ let cursor: string | undefined = 'c'
315
+ while (cursor) {
316
+ path.unshift(cursor)
317
+ cursor = from.get(cursor) ?? undefined
318
+ }
319
+ assert.deepEqual(path, ['start', 'a', 'b', 'a#1', 'c'])
320
+ })
321
+
243
322
  test('inline graph should execute converging next node only once', async () => {
244
323
  const ws = new InMemoryWorkflowService()
245
324
  let cCalls = 0
@@ -618,9 +697,14 @@ describe('graph-runner bugs', () => {
618
697
  const cJob = enqueued.find((e) => e.data?.stepName === 'c')
619
698
  assert.ok(cJob, 'node c should have been enqueued')
620
699
  assert.equal(
621
- cJob!.options,
622
- undefined,
623
- 'no retries → no queue options (preserves prior queue defaults)'
700
+ cJob!.options?.attempts,
701
+ DEFAULT_STEP_RETRIES + 1,
702
+ 'no retries → workflow default (5) + 1 attempt; queue never decides retries'
703
+ )
704
+ assert.equal(
705
+ cJob!.options?.backoff,
706
+ 'exponential',
707
+ 'default retries get exponential backoff so they ride out transient outages'
624
708
  )
625
709
  })
626
710
  })
@@ -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,10 +556,11 @@ 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
  }
@@ -5,9 +5,12 @@ 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'
11
14
 
12
15
  // Internal registration functions (used by generated code)
13
16
  export { addWorkflow } from './dsl/workflow-runner.js'