@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.
- package/CHANGELOG.md +113 -0
- package/dist/errors/error-handler.d.ts +8 -0
- package/dist/errors/error-handler.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/services/in-memory-queue-service.d.ts +9 -0
- package/dist/services/in-memory-queue-service.js +42 -11
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +19 -1
- package/dist/services/workflow-service.d.ts +3 -0
- package/dist/testing/service-tests.js +35 -0
- package/dist/types/core.types.d.ts +1 -0
- package/dist/wirings/cli/cli-runner.js +12 -3
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
- package/dist/wirings/workflow/graph/graph-runner.js +168 -106
- package/dist/wirings/workflow/index.d.ts +4 -1
- package/dist/wirings/workflow/index.js +4 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +95 -5
- package/dist/wirings/workflow/pikku-workflow-service.js +323 -175
- package/dist/wirings/workflow/run-timeline.d.ts +85 -0
- package/dist/wirings/workflow/run-timeline.js +153 -0
- 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 +7 -0
- package/package.json +2 -1
- package/src/dev/hot-reload.test.ts +5 -0
- package/src/errors/error-handler.ts +10 -0
- package/src/index.ts +1 -0
- package/src/services/in-memory-queue-service.test.ts +97 -0
- package/src/services/in-memory-queue-service.ts +51 -16
- package/src/services/in-memory-workflow-service.ts +27 -1
- package/src/services/workflow-service.ts +9 -0
- package/src/testing/service-tests.ts +65 -0
- package/src/types/core.types.ts +4 -0
- package/src/wirings/cli/cli-runner.ts +11 -3
- 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 +159 -3
- package/src/wirings/workflow/graph/graph-runner.ts +253 -142
- package/src/wirings/workflow/index.ts +17 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +447 -213
- package/src/wirings/workflow/run-timeline.test.ts +211 -0
- package/src/wirings/workflow/run-timeline.ts +241 -0
- 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 +7 -0
- 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(
|
|
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 ??
|
|
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:
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
330
|
-
const node = nodes[
|
|
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,
|
|
409
|
+
await queueGraphNode(workflowService, runId, graphName, fire.instanceKey, node.rpcName, resolvedInput, node, fire.fromStepName);
|
|
338
410
|
}
|
|
339
411
|
}
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
521
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|