@pikku/core 0.12.36 → 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 (35) hide show
  1. package/CHANGELOG.md +67 -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/services/meta-service.d.ts +4 -0
  5. package/dist/services/meta-service.js +9 -0
  6. package/dist/wirings/gateway/gateway.types.d.ts +4 -1
  7. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  8. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  9. package/dist/wirings/workflow/index.d.ts +2 -1
  10. package/dist/wirings/workflow/index.js +2 -1
  11. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  12. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  13. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  14. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  15. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  16. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  17. package/package.json +2 -1
  18. package/src/dev/hot-reload.test.ts +5 -0
  19. package/src/services/in-memory-workflow-service.ts +25 -1
  20. package/src/services/meta-service.ts +15 -0
  21. package/src/wirings/gateway/gateway.types.ts +6 -1
  22. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  23. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  24. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  25. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  26. package/src/wirings/workflow/index.ts +3 -0
  27. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  28. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  29. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  30. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  31. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  32. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  33. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  34. package/src/wirings/workflow/workflow.types.ts +5 -0
  35. 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,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'