@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,70 @@
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
+
61
+ ## 0.12.37
62
+
63
+ ### Patch Changes
64
+
65
+ - ae7fc5d: Include gateway platform and auth fields in inspected gateway metadata.
66
+ - fa7a09c: Add gateway metadata generation and display enabled gateways in the console.
67
+
1
68
  ## 0.12.36
2
69
 
3
70
  ### 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) {
@@ -12,6 +12,7 @@ import type { CredentialDefinitionsMeta } from '../wirings/credential/credential
12
12
  import type { VariableDefinitionsMeta } from '../wirings/variable/variable.types.js';
13
13
  import type { FunctionMeta, FunctionsMeta, MiddlewareMetadata, PermissionMetadata } from '../types/core.types.js';
14
14
  import type { AIAgentMeta } from '../wirings/ai-agent/ai-agent.types.js';
15
+ import type { GatewaysMeta } from '../wirings/gateway/gateway.types.js';
15
16
  export type { FunctionMeta, FunctionsMeta, MiddlewareMetadata as MiddlewareMeta, PermissionMetadata as PermissionMeta, };
16
17
  export type AgentsMeta = AIAgentMeta;
17
18
  export type AgentMeta = AIAgentMeta[string];
@@ -136,6 +137,7 @@ export interface MetaService {
136
137
  getQueueMeta(): Promise<QueueWorkersMeta>;
137
138
  getCliMeta(): Promise<CLIMeta>;
138
139
  getMcpMeta(): Promise<MCPMeta>;
140
+ getGatewayMeta(): Promise<GatewaysMeta>;
139
141
  getRpcMeta(): Promise<RPCMetaRecord>;
140
142
  getWorkflowMeta(): Promise<WorkflowsMeta>;
141
143
  getTriggerMeta(): Promise<TriggerMeta>;
@@ -166,6 +168,7 @@ export declare class LocalMetaService implements MetaService {
166
168
  private queueMetaCache;
167
169
  private cliMetaCache;
168
170
  private mcpMetaCache;
171
+ private gatewayMetaCache;
169
172
  private rpcMetaCache;
170
173
  private workflowMetaCache;
171
174
  private triggerMetaCache;
@@ -191,6 +194,7 @@ export declare class LocalMetaService implements MetaService {
191
194
  getQueueMeta(): Promise<QueueWorkersMeta>;
192
195
  getCliMeta(): Promise<CLIMeta>;
193
196
  getMcpMeta(): Promise<MCPMeta>;
197
+ getGatewayMeta(): Promise<GatewaysMeta>;
194
198
  getRpcMeta(): Promise<RPCMetaRecord>;
195
199
  getWorkflowMeta(): Promise<WorkflowsMeta>;
196
200
  getTriggerMeta(): Promise<TriggerMeta>;
@@ -12,6 +12,7 @@ export class LocalMetaService {
12
12
  queueMetaCache = null;
13
13
  cliMetaCache = null;
14
14
  mcpMetaCache = null;
15
+ gatewayMetaCache = null;
15
16
  rpcMetaCache = null;
16
17
  workflowMetaCache = null;
17
18
  triggerMetaCache = null;
@@ -59,6 +60,7 @@ export class LocalMetaService {
59
60
  this.queueMetaCache = null;
60
61
  this.cliMetaCache = null;
61
62
  this.mcpMetaCache = null;
63
+ this.gatewayMetaCache = null;
62
64
  this.rpcMetaCache = null;
63
65
  this.workflowMetaCache = null;
64
66
  this.triggerMetaCache = null;
@@ -148,6 +150,13 @@ export class LocalMetaService {
148
150
  }
149
151
  return this.mcpMetaCache;
150
152
  }
153
+ async getGatewayMeta() {
154
+ if (this.gatewayMetaCache)
155
+ return this.gatewayMetaCache;
156
+ const content = await this.readMetaJson('gateway', 'pikku-gateway-wirings-meta');
157
+ this.gatewayMetaCache = content ? JSON.parse(content) : {};
158
+ return this.gatewayMetaCache;
159
+ }
151
160
  async getRpcMeta() {
152
161
  if (this.rpcMetaCache)
153
162
  return this.rpcMetaCache;
@@ -92,13 +92,14 @@ export type GatewayTransportType = 'webhook' | 'websocket' | 'listener';
92
92
  /**
93
93
  * Core gateway configuration for wireGateway()
94
94
  */
95
- export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>, PikkuPermission extends CorePikkuPermission = CorePikkuPermission, PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware> = {
95
+ export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>, PikkuPermission extends CorePikkuPermission = CorePikkuPermission, PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware> = Partial<Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>> & {
96
96
  /** Unique name for this gateway */
97
97
  name: string;
98
98
  /** Transport type */
99
99
  type: GatewayTransportType;
100
100
  /** HTTP route for webhook/websocket types */
101
101
  route?: string;
102
+ platform?: string;
102
103
  /** The gateway adapter (parse inbound, send outbound) */
103
104
  adapter: GatewayAdapter;
104
105
  /** The handler function that processes parsed messages */
@@ -119,6 +120,8 @@ export type GatewayMeta = CommonWireMeta & {
119
120
  name: string;
120
121
  type: GatewayTransportType;
121
122
  route?: string;
123
+ platform?: string;
124
+ auth?: boolean;
122
125
  gateway: true;
123
126
  };
124
127
  /**
@@ -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
- /** The unique step ID */
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(name))
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 ?? 0,
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: rawBranch, } = await workflowService.getCompletedGraphState(runId);
276
- const completedNodeIds = remapStepNamesToNodeIds(rawCompleted, nodes, graphName);
277
- const completedNodeIdSet = new Set(completedNodeIds);
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 candidateNodes = new Set();
294
- for (const nodeId of completedNodeIds) {
295
- const node = nodes[nodeId];
296
- if (!node?.next)
297
- continue;
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 nodeId of nodesToQueue) {
330
- const node = nodes[nodeId];
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, nodeId, node.rpcName, resolvedInput, node);
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
- queueStepWorker(runId: string, stepName: string, rpcName: string, data: any, stepOptions?: WorkflowStepOptions): Promise<void>;
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;