@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.
- package/CHANGELOG.md +60 -0
- package/dist/services/in-memory-workflow-service.d.ts +7 -2
- package/dist/services/in-memory-workflow-service.js +17 -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/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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
## 0.12.38
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
|
|
6
|
+
|
|
7
|
+
The workflow — not the queue — now owns step retry policy, and each step
|
|
8
|
+
invocation gets a stable idempotency key.
|
|
9
|
+
- **Default `retries: 5` with exponential backoff.** A step with no `retries`
|
|
10
|
+
previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
|
|
11
|
+
no backoff) so retries fired instantly and couldn't outlast a transient
|
|
12
|
+
outage. Retries now default to 5 with backoff, resolved at the workflow layer.
|
|
13
|
+
- **`retries: 0` is honored.** Dispatch previously passed `undefined` options
|
|
14
|
+
for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
|
|
15
|
+
default. The resolved policy now always sets `attempts` (`retries: 0` →
|
|
16
|
+
`attempts: 1`), so the queue never second-guesses the workflow. The persisted
|
|
17
|
+
step retries and the dispatched `attempts` are resolved together so
|
|
18
|
+
"retries exhausted" and "no more redeliveries" are the same event.
|
|
19
|
+
- **`workflowStep.invocationId`** — a deterministic, dependency-free
|
|
20
|
+
`uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
|
|
21
|
+
attempt), it is identical across retries, so a step can dedupe on it
|
|
22
|
+
(`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
|
|
23
|
+
- **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
|
|
24
|
+
so a step's backoff silently never applied on Redis), and `registerQueues`
|
|
25
|
+
throws a clear error when no logger is available (matching queue-pg-boss).
|
|
26
|
+
- **Dispatch failures are recoverable, not fatal.** A step is now marked
|
|
27
|
+
`scheduled` only _after_ it is successfully handed to its transport (queue or
|
|
28
|
+
scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
|
|
29
|
+
it, instead of stranding it in `scheduled` (replay would pause forever on a
|
|
30
|
+
job that was never enqueued). A transport outage (e.g. pg-boss momentarily
|
|
31
|
+
down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
|
|
32
|
+
treats as transient: the run is left running and the orchestrator job is
|
|
33
|
+
rethrown for redelivery (it replays idempotently from the snapshot) rather
|
|
34
|
+
than the whole run being marked `failed`. The orchestrator job now also
|
|
35
|
+
carries its own retry policy, so this holds even when the orchestrator queue
|
|
36
|
+
is configured `retry_limit 0`. A genuine step error still fails the run.
|
|
37
|
+
- **Same step name can be invoked multiple times in one run.** Step rows are now
|
|
38
|
+
keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
|
|
39
|
+
physical key (`name` for the first, `name#N` for repeats), so a literal
|
|
40
|
+
duplicate name no longer clobbers the earlier step's state. The first reach
|
|
41
|
+
keeps the bare name, so existing rows, graph-node matching and `invocationId`s
|
|
42
|
+
are unchanged. Ordinals are derived deterministically from DSL execution order
|
|
43
|
+
and reset each replay.
|
|
44
|
+
- **Step provenance (`fromStepName`) + graph cycles.** Every step now records
|
|
45
|
+
the predecessor it was scheduled from (`fromStepName`; entry steps have none),
|
|
46
|
+
persisted on the step row across all stores (in-memory, kysely, redis,
|
|
47
|
+
mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
|
|
48
|
+
exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
|
|
49
|
+
consumers get the stable predecessor key without a second persisted id —
|
|
50
|
+
`fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
|
|
51
|
+
minted per row, is not). This makes the walked path reconstructable even when
|
|
52
|
+
a node is reached more than once: in `a → b → a → c` the second `a` is a
|
|
53
|
+
distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
|
|
54
|
+
The graph runner now supports **cycles**: a forward edge into an
|
|
55
|
+
already-started node still collapses to a single run (joins/diamonds are
|
|
56
|
+
unchanged), but a _back-edge_ — one whose target can reach its source — fires
|
|
57
|
+
a fresh ordinal instance, so a node can loop back to itself. Termination is
|
|
58
|
+
the graph's responsibility (branch routing must converge); the engine enforces
|
|
59
|
+
no visit cap.
|
|
60
|
+
|
|
1
61
|
## 0.12.37
|
|
2
62
|
|
|
3
63
|
### Patch Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
|
|
2
2
|
import type { SerializedError } from '../types/core.types.js';
|
|
3
|
-
import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
3
|
+
import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
4
4
|
/**
|
|
5
5
|
* In-memory implementation of WorkflowService for inline-only execution
|
|
6
6
|
*
|
|
@@ -35,7 +35,7 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
35
35
|
stepName: string;
|
|
36
36
|
}>>;
|
|
37
37
|
protected updateRunStatusImpl(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
38
|
-
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions): Promise<StepState>;
|
|
38
|
+
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
|
|
39
39
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
40
40
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
41
41
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
@@ -65,6 +65,11 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
65
65
|
branchKeys: Record<string, string>;
|
|
66
66
|
}>;
|
|
67
67
|
getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
68
|
+
getStepInstances(runId: string): Promise<Array<{
|
|
69
|
+
stepName: string;
|
|
70
|
+
status: StepStatus;
|
|
71
|
+
fromStepName?: string;
|
|
72
|
+
}>>;
|
|
68
73
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
69
74
|
setBranchKey(runId: string, nodeId: string, branchKey: string): Promise<void>;
|
|
70
75
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
@@ -78,7 +78,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
78
78
|
run.error = error;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
|
|
81
|
+
async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
82
82
|
const stepId = randomUUID();
|
|
83
83
|
const now = new Date();
|
|
84
84
|
const step = {
|
|
@@ -87,6 +87,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
87
87
|
attemptCount: 1,
|
|
88
88
|
retries: stepOptions?.retries,
|
|
89
89
|
retryDelay: stepOptions?.retryDelay,
|
|
90
|
+
fromStepName,
|
|
90
91
|
createdAt: now,
|
|
91
92
|
updatedAt: now,
|
|
92
93
|
stepName,
|
|
@@ -189,6 +190,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
189
190
|
attemptCount: failedStep.attemptCount + 1,
|
|
190
191
|
retries: failedStep.retries,
|
|
191
192
|
retryDelay: failedStep.retryDelay,
|
|
193
|
+
fromStepName: failedStep.fromStepName,
|
|
192
194
|
createdAt: now,
|
|
193
195
|
updatedAt: now,
|
|
194
196
|
stepName: stepName,
|
|
@@ -317,6 +319,20 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
317
319
|
}
|
|
318
320
|
return nodeIds.filter((id) => !existingSteps.has(id));
|
|
319
321
|
}
|
|
322
|
+
async getStepInstances(runId) {
|
|
323
|
+
const prefix = `${runId}:`;
|
|
324
|
+
const instances = [];
|
|
325
|
+
for (const [key, step] of this.steps.entries()) {
|
|
326
|
+
if (!key.startsWith(prefix))
|
|
327
|
+
continue;
|
|
328
|
+
instances.push({
|
|
329
|
+
stepName: key.substring(prefix.length),
|
|
330
|
+
status: step.status,
|
|
331
|
+
fromStepName: step.fromStepName,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return instances;
|
|
335
|
+
}
|
|
320
336
|
async getNodeResults(runId, nodeIds) {
|
|
321
337
|
const results = {};
|
|
322
338
|
for (const nodeId of nodeIds) {
|
|
@@ -271,10 +271,28 @@ export type WorkflowStepMeta = RpcStepMeta | BranchStepMeta | ParallelGroupStepM
|
|
|
271
271
|
export interface WorkflowStepWire {
|
|
272
272
|
/** The workflow run ID */
|
|
273
273
|
runId: string;
|
|
274
|
-
/**
|
|
274
|
+
/**
|
|
275
|
+
* The step row ID. Whether it stays the same or is minted fresh per attempt is
|
|
276
|
+
* STORE-SPECIFIC (in-memory mints a new one each attempt; the SQL store reuses
|
|
277
|
+
* the row) — do NOT use it as a dedupe key. Use `invocationId`.
|
|
278
|
+
*/
|
|
275
279
|
stepId: string;
|
|
280
|
+
/**
|
|
281
|
+
* Stable identity of this step invocation — the idempotency / dedupe key.
|
|
282
|
+
* Identical across every retry of the same call (derived from runId + step
|
|
283
|
+
* name) regardless of storage backend, so a step can `ON CONFLICT (invocationId)`
|
|
284
|
+
* or pass it as an external idempotency key and have retries collapse onto the
|
|
285
|
+
* first attempt.
|
|
286
|
+
*/
|
|
287
|
+
invocationId: string;
|
|
276
288
|
/** Current attempt number (1-indexed, increments on retry) */
|
|
277
289
|
attemptCount: number;
|
|
290
|
+
/**
|
|
291
|
+
* Invocation ID of the predecessor step this one was reached from (the walked
|
|
292
|
+
* transition/edge). Undefined for entry steps. Lets a step know its origin —
|
|
293
|
+
* e.g. in a cyclic graph `a → b → a → c`, the second `a` carries `b`'s id.
|
|
294
|
+
*/
|
|
295
|
+
fromInvocationId?: string;
|
|
278
296
|
}
|
|
279
297
|
/**
|
|
280
298
|
* Workflow wire object for DSL workflows
|
|
@@ -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,51 +387,26 @@ 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
|
export async function executeGraphStep(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName) {
|
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
5
6
|
export { addWorkflow } from './dsl/workflow-runner.js';
|
|
6
7
|
export { template, type TemplateString } from './graph/template.js';
|
|
7
8
|
export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGraphResult, } from './graph/wire-workflow-graph.js';
|
|
@@ -1,7 +1,8 @@
|
|
|
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';
|
|
5
6
|
// Internal registration functions (used by generated code)
|
|
6
7
|
export { addWorkflow } from './dsl/workflow-runner.js';
|
|
7
8
|
// Graph helpers (template, pikkuWorkflowGraph)
|
|
@@ -1,7 +1,16 @@
|
|
|
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 { JobOptions } from '../queue/queue.types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Default number of retries for a workflow step when none is specified. The
|
|
8
|
+
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
9
|
+
* sets its own `retries` (including `retries: 0` to opt out entirely). Picked >0
|
|
10
|
+
* so a transient failure (a DB blip, a downstream restart, a deploy) is ridden
|
|
11
|
+
* out by default; safe because every step gets a stable `invocationId` to dedupe on.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_STEP_RETRIES = 5;
|
|
5
14
|
/**
|
|
6
15
|
* Exception thrown when workflow needs to pause for async step
|
|
7
16
|
*/
|
|
@@ -26,6 +35,21 @@ export declare class WorkflowSuspendedException extends Error {
|
|
|
26
35
|
readonly reason: string;
|
|
27
36
|
constructor(runId: string, reason: string);
|
|
28
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Thrown when a step (or the orchestrator) could not be enqueued — the queue
|
|
40
|
+
* itself failed (e.g. pg-boss is momentarily down), NOT the step's own logic.
|
|
41
|
+
* This is transient infrastructure failure: the run is left untouched (the step
|
|
42
|
+
* stays `pending`, the run stays running) and the orchestrator job is rethrown
|
|
43
|
+
* so the queue redelivers it and the workflow replays from its snapshot. Treat
|
|
44
|
+
* it as non-terminal — never mark the run `failed` for it.
|
|
45
|
+
*/
|
|
46
|
+
export declare class WorkflowDispatchException extends Error {
|
|
47
|
+
readonly runId: string;
|
|
48
|
+
readonly stepName: string;
|
|
49
|
+
constructor(runId: string, stepName: string, options?: {
|
|
50
|
+
cause?: unknown;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
29
53
|
/**
|
|
30
54
|
* Error class for workflow not found
|
|
31
55
|
*/
|
|
@@ -124,8 +148,8 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
124
148
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
125
149
|
* @returns Step state with generated stepId
|
|
126
150
|
*/
|
|
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>;
|
|
151
|
+
insertStepState(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
|
|
152
|
+
protected abstract insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
|
|
129
153
|
/**
|
|
130
154
|
* Get step state by cache key (read-only)
|
|
131
155
|
* @param runId - Run ID
|
|
@@ -216,6 +240,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
216
240
|
* @returns Node IDs that don't have a step yet
|
|
217
241
|
*/
|
|
218
242
|
abstract getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
|
|
243
|
+
/**
|
|
244
|
+
* List every step instance of a run (any status) with its predecessor.
|
|
245
|
+
* Drives bounded graph revisits: the runner counts instances per logical node
|
|
246
|
+
* and treats each `fromStepName → node` as a once-fired transition.
|
|
247
|
+
* @param runId - Run ID
|
|
248
|
+
*/
|
|
249
|
+
abstract getStepInstances(runId: string): Promise<Array<{
|
|
250
|
+
stepName: string;
|
|
251
|
+
status: StepStatus;
|
|
252
|
+
fromStepName?: string;
|
|
253
|
+
}>>;
|
|
219
254
|
/**
|
|
220
255
|
* Get results for specific nodes
|
|
221
256
|
* @param runId - Run ID
|
|
@@ -262,7 +297,17 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
262
297
|
* @param runId - Run ID
|
|
263
298
|
*/
|
|
264
299
|
resumeWorkflow(runId: string, workflowName?: string): Promise<void>;
|
|
265
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
302
|
+
* sole source of truth for retries: an explicitly-set `retries` (including 0)
|
|
303
|
+
* is always honored, an unset one defaults to {@link DEFAULT_STEP_RETRIES},
|
|
304
|
+
* and we ALWAYS pass `attempts` so the queue can never fall back to its own
|
|
305
|
+
* default — which would re-run a step the workflow said not to retry. Backoff
|
|
306
|
+
* defaults to exponential whenever there's at least one retry, so retries ride
|
|
307
|
+
* out a transient outage instead of firing instantly.
|
|
308
|
+
*/
|
|
309
|
+
protected resolveStepJobOptions(stepOptions?: WorkflowStepOptions): JobOptions;
|
|
310
|
+
queueStepWorker(runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<void>;
|
|
266
311
|
/**
|
|
267
312
|
* Execute a workflow sleep step completion
|
|
268
313
|
* Sets the step result to null and resumes the workflow
|
|
@@ -289,7 +334,7 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
289
334
|
* @returns true if dispatch was async (caller should pause), false to fall
|
|
290
335
|
* through to the inline execution path.
|
|
291
336
|
*/
|
|
292
|
-
protected dispatchStep(runId: string, stepName: string, rpcName: string, data: unknown, stepOptions?: WorkflowStepOptions): Promise<boolean>;
|
|
337
|
+
protected dispatchStep(runId: string, stepName: string, rpcName: string, data: unknown, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<boolean>;
|
|
293
338
|
/**
|
|
294
339
|
* Schedule a workflow sleep wakeup at the given duration.
|
|
295
340
|
*
|
|
@@ -317,7 +362,20 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
317
362
|
pollIntervalMs?: number;
|
|
318
363
|
wire?: WorkflowRunWire;
|
|
319
364
|
}): Promise<any>;
|
|
365
|
+
private stepOrdinals;
|
|
366
|
+
private stepLineage;
|
|
367
|
+
private resetStepOrdinals;
|
|
368
|
+
/** The step the DSL walk last reached (the predecessor for the next step). */
|
|
369
|
+
private lastStepName;
|
|
370
|
+
/**
|
|
371
|
+
* Physical, replay-stable key for the Nth reach of `logicalStepName` in a run:
|
|
372
|
+
* bare name for the first reach (ordinal 0, unchanged behavior), `name#N` for
|
|
373
|
+
* repeats — so the same literal step name can be invoked multiple times without
|
|
374
|
+
* the rows clobbering. Deterministic given a deterministic DSL body.
|
|
375
|
+
*/
|
|
376
|
+
private nextStepKey;
|
|
320
377
|
runWorkflowJob(runId: string, rpcService: any): Promise<void>;
|
|
378
|
+
private runWorkflowJobInner;
|
|
321
379
|
private onChildWorkflowCompleted;
|
|
322
380
|
private onChildWorkflowFailed;
|
|
323
381
|
private runVersionMismatchFallback;
|