@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.
- package/CHANGELOG.md +67 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -1
- package/dist/services/meta-service.d.ts +4 -0
- package/dist/services/meta-service.js +9 -0
- package/dist/wirings/gateway/gateway.types.d.ts +4 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +117 -45
- package/dist/wirings/workflow/index.d.ts +2 -1
- package/dist/wirings/workflow/index.js +2 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
- package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
- package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
- package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
- package/dist/wirings/workflow/workflow.types.d.ts +5 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/services/in-memory-workflow-service.ts +25 -1
- package/src/services/meta-service.ts +15 -0
- package/src/wirings/gateway/gateway.types.ts +6 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
- package/src/wirings/workflow/graph/graph-runner.ts +158 -56
- package/src/wirings/workflow/index.ts +3 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
- package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
- package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
- package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
- package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
- package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
- package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
- package/src/wirings/workflow/workflow.types.ts +5 -0
- 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(
|
|
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 ??
|
|
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:
|
|
498
|
+
branchKeys: branchByStep,
|
|
368
499
|
} = await workflowService.getCompletedGraphState(runId)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 (
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
440
|
-
const node = nodes[
|
|
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
|
-
|
|
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'
|