bunqueue 2.8.48 → 2.8.50

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 (129) hide show
  1. package/README.md +19 -9
  2. package/dist/application/contextFactory.d.ts +4 -2
  3. package/dist/application/contextFactory.js +3 -0
  4. package/dist/application/flowFailureRecovery.d.ts +17 -0
  5. package/dist/application/flowFailureRecovery.js +92 -0
  6. package/dist/application/operations/ack.d.ts +3 -2
  7. package/dist/application/operations/ack.js +34 -8
  8. package/dist/application/operations/flowPush.d.ts +9 -0
  9. package/dist/application/operations/flowPush.js +112 -0
  10. package/dist/application/operations/flowTopologyValidation.d.ts +3 -0
  11. package/dist/application/operations/flowTopologyValidation.js +101 -0
  12. package/dist/application/operations/flowValidation.d.ts +3 -0
  13. package/dist/application/operations/flowValidation.js +166 -0
  14. package/dist/application/operations/push.d.ts +3 -0
  15. package/dist/application/operations/push.js +2 -2
  16. package/dist/application/operations/pushInsert.d.ts +7 -1
  17. package/dist/application/operations/pushInsert.js +18 -8
  18. package/dist/application/operations/pushLocks.d.ts +1 -0
  19. package/dist/application/operations/pushLocks.js +5 -1
  20. package/dist/application/operations/queryOperations.js +7 -6
  21. package/dist/application/queueManager.d.ts +5 -0
  22. package/dist/application/queueManager.js +195 -60
  23. package/dist/cli/commandRouter.d.ts +1 -1
  24. package/dist/client/flow.d.ts +3 -6
  25. package/dist/client/flow.js +71 -210
  26. package/dist/client/flowAtomic.d.ts +9 -0
  27. package/dist/client/flowAtomic.js +24 -0
  28. package/dist/client/flowJobCoreMethods.d.ts +28 -0
  29. package/dist/client/flowJobCoreMethods.js +151 -0
  30. package/dist/client/flowJobDependencies.d.ts +7 -0
  31. package/dist/client/flowJobDependencies.js +64 -0
  32. package/dist/client/flowJobFactory.d.ts +11 -24
  33. package/dist/client/flowJobFactory.js +91 -360
  34. package/dist/client/flowJobMoveMethods.d.ts +16 -0
  35. package/dist/client/flowJobMoveMethods.js +112 -0
  36. package/dist/client/flowJobTypes.d.ts +29 -0
  37. package/dist/client/flowJobTypes.js +6 -0
  38. package/dist/client/flowLegacyPlan.d.ts +16 -0
  39. package/dist/client/flowLegacyPlan.js +118 -0
  40. package/dist/client/flowOptions.d.ts +10 -0
  41. package/dist/client/flowOptions.js +44 -0
  42. package/dist/client/flowPlan.d.ts +16 -0
  43. package/dist/client/flowPlan.js +93 -0
  44. package/dist/client/flowPush.js +4 -1
  45. package/dist/client/flowReader.d.ts +10 -0
  46. package/dist/client/flowReader.js +147 -0
  47. package/dist/client/jobHelpers.js +2 -0
  48. package/dist/client/workflow/clock.d.ts +3 -1
  49. package/dist/client/workflow/clock.js +18 -1
  50. package/dist/client/workflow/compensationChild.d.ts +30 -0
  51. package/dist/client/workflow/compensationChild.js +65 -0
  52. package/dist/client/workflow/compensationPass.d.ts +16 -0
  53. package/dist/client/workflow/compensationPass.js +96 -0
  54. package/dist/client/workflow/compensationSupport.d.ts +24 -0
  55. package/dist/client/workflow/compensationSupport.js +57 -0
  56. package/dist/client/workflow/compensator.d.ts +11 -50
  57. package/dist/client/workflow/compensator.js +22 -374
  58. package/dist/client/workflow/definitionGuard.d.ts +11 -0
  59. package/dist/client/workflow/definitionGuard.js +22 -0
  60. package/dist/client/workflow/engine.d.ts +2 -2
  61. package/dist/client/workflow/engine.js +8 -8
  62. package/dist/client/workflow/eventTypes.d.ts +44 -0
  63. package/dist/client/workflow/eventTypes.js +2 -0
  64. package/dist/client/workflow/executionTypes.d.ts +133 -0
  65. package/dist/client/workflow/executionTypes.js +2 -0
  66. package/dist/client/workflow/executor.d.ts +4 -17
  67. package/dist/client/workflow/executor.js +50 -208
  68. package/dist/client/workflow/executorLifecycle.d.ts +18 -0
  69. package/dist/client/workflow/executorLifecycle.js +69 -0
  70. package/dist/client/workflow/executorNodes.d.ts +17 -0
  71. package/dist/client/workflow/executorNodes.js +138 -0
  72. package/dist/client/workflow/identity.d.ts +2 -0
  73. package/dist/client/workflow/identity.js +8 -0
  74. package/dist/client/workflow/index.d.ts +1 -1
  75. package/dist/client/workflow/loops.d.ts +3 -5
  76. package/dist/client/workflow/loops.js +25 -23
  77. package/dist/client/workflow/mapRunner.d.ts +4 -0
  78. package/dist/client/workflow/mapRunner.js +45 -0
  79. package/dist/client/workflow/recovery.js +10 -3
  80. package/dist/client/workflow/runner.d.ts +2 -21
  81. package/dist/client/workflow/runner.js +39 -104
  82. package/dist/client/workflow/runnerTiming.d.ts +14 -0
  83. package/dist/client/workflow/runnerTiming.js +66 -0
  84. package/dist/client/workflow/stepTypes.d.ts +171 -0
  85. package/dist/client/workflow/stepTypes.js +3 -0
  86. package/dist/client/workflow/store.d.ts +17 -17
  87. package/dist/client/workflow/store.js +65 -100
  88. package/dist/client/workflow/storeExecutionCodec.d.ts +11 -0
  89. package/dist/client/workflow/storeExecutionCodec.js +34 -0
  90. package/dist/client/workflow/storeListing.d.ts +11 -0
  91. package/dist/client/workflow/storeListing.js +45 -0
  92. package/dist/client/workflow/storeMaintenance.d.ts +4 -0
  93. package/dist/client/workflow/storeMaintenance.js +40 -0
  94. package/dist/client/workflow/storeSignals.d.ts +9 -0
  95. package/dist/client/workflow/storeSignals.js +38 -2
  96. package/dist/client/workflow/subWorkflowRunner.d.ts +13 -0
  97. package/dist/client/workflow/subWorkflowRunner.js +40 -0
  98. package/dist/client/workflow/types.d.ts +4 -356
  99. package/dist/client/workflow/types.js +1 -3
  100. package/dist/client/workflow/waitFor.js +39 -26
  101. package/dist/client/workflow/workflow.d.ts +16 -59
  102. package/dist/client/workflow/workflow.js +53 -179
  103. package/dist/client/workflow/workflowDecisions.d.ts +11 -0
  104. package/dist/client/workflow/workflowDecisions.js +27 -0
  105. package/dist/client/workflow/workflowDefinition.d.ts +16 -0
  106. package/dist/client/workflow/workflowDefinition.js +123 -0
  107. package/dist/client/workflow/workflowIntrospection.d.ts +5 -0
  108. package/dist/client/workflow/workflowIntrospection.js +46 -0
  109. package/dist/client/workflow/workflowValidation.d.ts +44 -0
  110. package/dist/client/workflow/workflowValidation.js +143 -0
  111. package/dist/domain/types/command.d.ts +7 -1
  112. package/dist/domain/types/flow.d.ts +25 -0
  113. package/dist/domain/types/flow.js +1 -0
  114. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  115. package/dist/infrastructure/persistence/schema.js +52 -3
  116. package/dist/infrastructure/persistence/sqlite.d.ts +16 -0
  117. package/dist/infrastructure/persistence/sqlite.js +129 -7
  118. package/dist/infrastructure/persistence/sqliteBatch.js +10 -4
  119. package/dist/infrastructure/persistence/sqliteSerializer.d.ts +2 -0
  120. package/dist/infrastructure/persistence/sqliteSerializer.js +18 -5
  121. package/dist/infrastructure/persistence/statements.d.ts +4 -0
  122. package/dist/infrastructure/persistence/statements.js +8 -2
  123. package/dist/infrastructure/server/handlerRoutes.js +3 -0
  124. package/dist/infrastructure/server/handlers/advanced.js +2 -2
  125. package/dist/infrastructure/server/handlers/flow.d.ts +7 -0
  126. package/dist/infrastructure/server/handlers/flow.js +11 -0
  127. package/dist/infrastructure/server/handlers/index.d.ts +1 -0
  128. package/dist/infrastructure/server/handlers/index.js +1 -0
  129. package/package.json +4 -2
@@ -0,0 +1,133 @@
1
+ /** Persisted workflow execution, step outcome, and store coordination types. */
2
+ /** Execution state */
3
+ export type ExecutionState = 'running' | 'waiting' | 'completed' | 'failed' | 'compensating'
4
+ /**
5
+ * A compensation failed definitively and the unwind stopped. Deliberately NOT
6
+ * terminal: the run parks until an operator retries or abandons the unwind.
7
+ */
8
+ | 'compensation-stuck';
9
+ /** What the engine did after a failure, independently of the failure reason. */
10
+ export type RollbackStatus =
11
+ /** Every eligible step was compensated successfully. */
12
+ 'completed'
13
+ /** The unwind ran past the pivot cutoff or had nothing left to do. */
14
+ | 'not-applicable'
15
+ /** A compensation failed definitively; remaining reversals were not attempted. */
16
+ | 'stuck';
17
+ /** Step execution state */
18
+ export type StepState = 'pending' | 'running' | 'completed' | 'failed';
19
+ /** Terminal outcome of one step's rollback. */
20
+ export type CompensationStatus = 'compensated' | 'compensation-failed' | 'compensation-skipped';
21
+ export interface CompensationOutcome {
22
+ status: CompensationStatus;
23
+ at: number;
24
+ /** Why it failed, or why it was skipped. */
25
+ error?: string;
26
+ }
27
+ /** Record of a step's execution */
28
+ export interface StepRecord {
29
+ status: StepState;
30
+ result?: unknown;
31
+ error?: string;
32
+ startedAt?: number;
33
+ completedAt?: number;
34
+ attempts?: number;
35
+ /**
36
+ * Whether this step declared a compensate handler when it ran. This survives a
37
+ * deployment that removes or renames the definition.
38
+ */
39
+ compensatable?: boolean;
40
+ /** forEach iteration item — persisted so compensation can restore __item */
41
+ loopItem?: unknown;
42
+ /** forEach iteration index — persisted so compensation can restore __index */
43
+ loopIndex?: number;
44
+ /** Exactly one terminal compensation outcome for every eligible step. */
45
+ compensation?: CompensationOutcome;
46
+ /** Idempotency key of the forward execution, persisted before the body runs. */
47
+ idempotencyKey?: string;
48
+ /** Child execution owned by a sub-workflow record. */
49
+ childExecutionId?: string;
50
+ /** Occurrence of this step name within the run — loops reuse a single name. */
51
+ occurrence?: number;
52
+ }
53
+ /** Full persisted execution state */
54
+ export interface Execution {
55
+ id: string;
56
+ workflowName: string;
57
+ state: ExecutionState;
58
+ input: unknown;
59
+ steps: Record<string, StepRecord>;
60
+ currentNodeIndex: number;
61
+ /** Flattened names selected by resolved branch paths. */
62
+ resolvedSteps?: string[];
63
+ /** Durable results of control-flow decisions, keyed by node/iteration identity. */
64
+ decisions?: Record<string, unknown>;
65
+ /** Structural identity of the sealed workflow definition that started this run. */
66
+ definitionHash?: string;
67
+ /** What happened to the rollback. Independent of `failureReason`. */
68
+ rollbackStatus?: RollbackStatus;
69
+ /** Why the run failed. Independent of `rollbackStatus`. */
70
+ failureReason?: string;
71
+ /**
72
+ * Node index at which `.pivot()` committed. Once set, backward recovery is off for
73
+ * the whole saga and recovery is forward-only.
74
+ */
75
+ committedAt?: number;
76
+ signals: Record<string, unknown>;
77
+ /** Parent execution that owns this sub-workflow child. */
78
+ parentExecutionId?: string;
79
+ createdAt: number;
80
+ updatedAt: number;
81
+ }
82
+ /** Handle returned from engine.start() */
83
+ export interface RunHandle {
84
+ id: string;
85
+ workflowName: string;
86
+ }
87
+ /** Stable, bounded pagination for execution listings. */
88
+ export interface ExecutionListOptions {
89
+ /** Page size. Defaults to 100 and is capped at 1000. */
90
+ limit?: number;
91
+ /** Number of rows to skip in the deterministic createdAt/id order. */
92
+ offset?: number;
93
+ }
94
+ /** Internal job data for step execution */
95
+ export interface StepJobData {
96
+ executionId: string;
97
+ workflowName: string;
98
+ nodeIndex: number;
99
+ }
100
+ /** Result of engine.recover() */
101
+ export interface RecoverResult {
102
+ /** Number of running executions re-enqueued */
103
+ running: number;
104
+ /** Number of waiting executions with re-armed timers */
105
+ waiting: number;
106
+ /** Number of compensating executions re-run */
107
+ compensating: number;
108
+ /** Total recovered */
109
+ total: number;
110
+ }
111
+ /** Result of WorkflowStore.recordSignal() */
112
+ export interface SignalOutcome {
113
+ /** Whether the execution row exists */
114
+ found: boolean;
115
+ /** True for the single caller that claimed the resume of a parked run */
116
+ resumed: boolean;
117
+ workflowName: string;
118
+ currentNodeIndex: number;
119
+ }
120
+ /** Result of WorkflowStore.parkForSignal() */
121
+ export interface ParkOutcome {
122
+ /** The awaited signal was already recorded — advance instead of parking */
123
+ signalPresent: boolean;
124
+ /** This caller transitioned the run to 'waiting' */
125
+ parked: boolean;
126
+ /** Signals as persisted, for refreshing a stale in-memory snapshot */
127
+ signals: Record<string, unknown>;
128
+ }
129
+ /** Options for cleanup */
130
+ export interface CleanupOptions {
131
+ maxAge: number;
132
+ states?: ExecutionState[];
133
+ }
@@ -0,0 +1,2 @@
1
+ /** Persisted workflow execution, step outcome, and store coordination types. */
2
+ export {};
@@ -3,7 +3,7 @@ import type { Queue } from '../queue/queue';
3
3
  import type { Workflow } from './workflow';
4
4
  import type { WorkflowStore } from './store';
5
5
  import type { WorkflowEmitter } from './emitter';
6
- import type { Execution, StepJobData, RunHandle, RecoverResult } from './types';
6
+ import type { Execution, ExecutionListOptions, StepJobData, RunHandle, RecoverResult } from './types';
7
7
  export declare class WorkflowExecutor {
8
8
  private readonly store;
9
9
  private readonly queue;
@@ -39,28 +39,15 @@ export declare class WorkflowExecutor {
39
39
  processStep(data: StepJobData): Promise<unknown>;
40
40
  private runNode;
41
41
  signal(executionId: string, event: string, payload: unknown): Promise<void>;
42
+ private get lifecycleDeps();
42
43
  /** Retry the compensation that parked the run, then finish the unwind. */
43
44
  resumeCompensation(executionId: string): Promise<void>;
44
45
  /** Give up on a parked unwind, recording the outstanding steps as skipped. */
45
46
  abandonCompensation(executionId: string): void;
46
47
  private get rollbackDeps();
47
48
  getExecution(id: string): Execution | null;
48
- listExecutions(wfName?: string, state?: Execution['state']): Execution[];
49
- private executeNode;
50
- private runStep;
51
- private runBranch;
52
- private runParallel;
53
- private runSubWorkflow;
54
- /**
55
- * Commit the saga. From here the run can still fail, but it can no longer be
56
- * rolled back: recovery past this point is forward-only.
57
- */
58
- private runPivot;
59
- /**
60
- * Run a node body that manages its own step records (loops, forEach, map), then
61
- * move on. These differ only in which executor they delegate to.
62
- */
63
- private runBody;
49
+ listExecutions(wfName?: string, state?: Execution['state'], options?: ExecutionListOptions): Execution[];
50
+ private get nodeDeps();
64
51
  private advance;
65
52
  private enqueue;
66
53
  private get waitForDeps();
@@ -1,13 +1,13 @@
1
- import { assertNoIndexCollision, assertNoDuplicateWaitFor, unusableEventName } from './workflow';
2
- import { executeStepWithRetry, executeParallelSteps, executeSubWorkflow, buildContext, } from './runner';
3
- import { executeDoUntil, executeDoWhile, executeForEach, executeMap } from './loops';
1
+ import { assertNoIndexCollision, assertNoDuplicateWaitFor } from './workflow';
4
2
  import { WaitForSignalError, runCompensation } from './compensator';
5
3
  import { abandonParkedCompensation, resumeCompensation, } from './rollbackControl';
6
4
  import { recoverExecutions } from './recovery';
7
- import { clearTimers, runWaitFor, scheduleTimeoutCheck } from './waitFor';
8
- import { clock } from './clock';
5
+ import { clearTimers, scheduleTimeoutCheck } from './waitFor';
9
6
  import { describeError } from './identity';
10
7
  import { claimKey, decideAdmission } from './admission';
8
+ import { signalExecution, startExecution } from './executorLifecycle';
9
+ import { executeWorkflowNode } from './executorNodes';
10
+ import { bindExecutionDefinition, WorkflowDefinitionMismatchError } from './definitionGuard';
11
11
  export class WorkflowExecutor {
12
12
  store;
13
13
  queue;
@@ -33,6 +33,12 @@ export class WorkflowExecutor {
33
33
  };
34
34
  }
35
35
  register(workflow) {
36
+ if (this.workflows.has(workflow.name)) {
37
+ throw new Error(`Workflow "${workflow.name}" is already registered`);
38
+ }
39
+ if (workflow.nodes.length === 0) {
40
+ throw new Error(`Workflow "${workflow.name}" has no steps`);
41
+ }
36
42
  const names = workflow.getStepNames();
37
43
  const dupes = names.filter((n, i) => names.indexOf(n) !== i);
38
44
  if (dupes.length > 0) {
@@ -40,32 +46,19 @@ export class WorkflowExecutor {
40
46
  }
41
47
  assertNoIndexCollision(workflow);
42
48
  assertNoDuplicateWaitFor(workflow);
49
+ const definitionHash = workflow.seal();
50
+ const conflict = this.store
51
+ .listActive(workflow.name)
52
+ .find((exec) => exec.state !== 'compensation-stuck' &&
53
+ exec.definitionHash !== undefined &&
54
+ exec.definitionHash !== definitionHash);
55
+ if (conflict) {
56
+ throw new WorkflowDefinitionMismatchError(conflict.id, workflow.name);
57
+ }
43
58
  this.workflows.set(workflow.name, workflow);
44
59
  }
45
60
  async start(workflowName, input, parentExecutionId) {
46
- const wf = this.workflows.get(workflowName);
47
- if (!wf)
48
- throw new Error(`Workflow "${workflowName}" not registered`);
49
- if (wf.nodes.length === 0)
50
- throw new Error(`Workflow "${workflowName}" has no steps`);
51
- const now = clock().now();
52
- const id = `wf_${now}_${clock().random().toString(36).slice(2, 10)}`;
53
- const exec = {
54
- id,
55
- workflowName,
56
- state: 'running',
57
- input,
58
- steps: {},
59
- currentNodeIndex: 0,
60
- signals: {},
61
- ...(parentExecutionId ? { parentExecutionId } : {}),
62
- createdAt: now,
63
- updatedAt: now,
64
- };
65
- this.store.save(exec);
66
- this.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
67
- await this.enqueue(exec);
68
- return { id, workflowName };
61
+ return await startExecution(this.lifecycleDeps, workflowName, input, parentExecutionId);
69
62
  }
70
63
  /**
71
64
  * Nodes this process is currently executing, keyed `<execution>:<nodeIndex>`.
@@ -104,12 +97,13 @@ export class WorkflowExecutor {
104
97
  }
105
98
  }
106
99
  async runNode(data, exec) {
107
- // If waiting, set back to running for timeout re-check
108
- if (exec.state === 'waiting')
109
- exec.state = 'running';
110
100
  const wf = this.workflows.get(exec.workflowName);
111
101
  if (!wf)
112
102
  throw new Error(`Workflow "${exec.workflowName}" not registered`);
103
+ bindExecutionDefinition(exec, wf, this.updateFn);
104
+ // If waiting, set back to running for timeout re-check
105
+ if (exec.state === 'waiting')
106
+ exec.state = 'running';
113
107
  const node = wf.nodes[data.nodeIndex];
114
108
  if (!node) {
115
109
  exec.state = 'completed';
@@ -118,7 +112,7 @@ export class WorkflowExecutor {
118
112
  return null;
119
113
  }
120
114
  try {
121
- await this.executeNode(exec, node, data.nodeIndex, wf);
115
+ await executeWorkflowNode(this.nodeDeps, exec, node, data.nodeIndex, wf);
122
116
  }
123
117
  catch (err) {
124
118
  if (err instanceof WaitForSignalError)
@@ -141,48 +135,17 @@ export class WorkflowExecutor {
141
135
  return null;
142
136
  }
143
137
  async signal(executionId, event, payload) {
144
- // Same predicate as registration. A name that cannot be stored has to fail here
145
- // too: a caller who signals it would otherwise get a clean return for a delivery
146
- // that went nowhere.
147
- const bad = unusableEventName(event);
148
- if (bad)
149
- throw new Error(`Cannot signal an event ${bad}`);
150
- // A finished run cannot receive anything. Accepting it wrote the payload into the
151
- // persisted row and emitted `signal:received`, so a dashboard reported an approval
152
- // against a run that had already ended and a closed audit record was mutated after
153
- // the fact, while the caller got a clean return for a delivery that did nothing.
154
- // A signal racing a run to its end is real, and rejecting is how the caller finds
155
- // out (`test/repro-workflow-operator-signal.test.ts`).
156
- const current = this.store.get(executionId);
157
- if (current && current.state !== 'running' && current.state !== 'waiting') {
158
- throw new Error(`Execution "${executionId}" is "${current.state}" and cannot receive the signal "${event}"`);
159
- }
160
- const timer = this.timeoutTimers.get(executionId);
161
- if (timer) {
162
- clock().clearTimeout(timer);
163
- this.timeoutTimers.delete(executionId);
164
- }
165
- // Record the payload and claim the resume in a single transaction that touches
166
- // only the `signals` and `state` columns. Writing through the store — rather than
167
- // mutating a snapshot and calling update() — is what keeps a concurrently
168
- // executing step from overwriting the payload with its own stale `signals`
169
- // (test/repro-workflow-signal-lost-update.test.ts).
170
- //
171
- // The claim is a conditional `state = 'waiting'` UPDATE, so duplicate or
172
- // concurrent signals collapse to exactly one resume and every step after the
173
- // waitFor runs exactly once. A signal that lands before the run parks records
174
- // its payload and returns; runWaitFor then consumes it via parkForSignal().
175
- const outcome = this.store.recordSignal(executionId, event, payload);
176
- if (!outcome.found)
177
- throw new Error(`Execution "${executionId}" not found`);
178
- this.emitter?.emitSignal('signal:received', executionId, outcome.workflowName, event, payload);
179
- if (!outcome.resumed)
180
- return;
181
- await this.queue.add('wf:step', {
182
- executionId,
183
- workflowName: outcome.workflowName,
184
- nodeIndex: outcome.currentNodeIndex,
185
- });
138
+ await signalExecution(this.lifecycleDeps, executionId, event, payload);
139
+ }
140
+ get lifecycleDeps() {
141
+ return {
142
+ store: this.store,
143
+ queue: this.queue,
144
+ workflows: this.workflows,
145
+ emitter: this.emitter,
146
+ timers: this.timeoutTimers,
147
+ enqueue: (exec) => this.enqueue(exec),
148
+ };
186
149
  }
187
150
  /** Retry the compensation that parked the run, then finish the unwind. */
188
151
  async resumeCompensation(executionId) {
@@ -202,140 +165,19 @@ export class WorkflowExecutor {
202
165
  getExecution(id) {
203
166
  return this.store.get(id);
204
167
  }
205
- listExecutions(wfName, state) {
206
- return this.store.list(wfName, state);
207
- }
208
- async executeNode(exec, node, idx, wf) {
209
- if (node.type === 'step')
210
- await this.runStep(exec, node.def, idx, wf);
211
- else if (node.type === 'branch')
212
- await this.runBranch(exec, node, idx, wf);
213
- else if (node.type === 'parallel')
214
- await this.runParallel(exec, node, idx, wf);
215
- else if (node.type === 'subWorkflow')
216
- await this.runSubWorkflow(exec, node, idx, wf);
217
- else if (node.type === 'doUntil')
218
- await this.runBody(exec, idx, wf, executeDoUntil, node.def);
219
- else if (node.type === 'doWhile')
220
- await this.runBody(exec, idx, wf, executeDoWhile, node.def);
221
- else if (node.type === 'forEach')
222
- await this.runBody(exec, idx, wf, executeForEach, node.def);
223
- else if (node.type === 'map')
224
- await this.runBody(exec, idx, wf, executeMap, node.def);
225
- else if (node.type === 'pivot')
226
- await this.runPivot(exec, idx, wf);
227
- else
228
- await runWaitFor(this.waitForDeps, exec, node, idx, wf);
229
- }
230
- async runStep(exec, def, idx, wf) {
231
- const ctx = buildContext(exec);
232
- await executeStepWithRetry(def, ctx, exec, { emitter: this.emitter, updateFn: this.updateFn });
233
- await this.advance(exec, idx + 1, wf);
234
- }
235
- async runBranch(exec, node, idx, wf) {
236
- const pathName = node.def.condition(buildContext(exec));
237
- const pathSteps = node.def.paths.get(pathName);
238
- if (pathSteps && pathSteps.length > 0) {
239
- for (const step of pathSteps) {
240
- await executeStepWithRetry(step, buildContext(exec), exec, {
241
- emitter: this.emitter,
242
- updateFn: this.updateFn,
243
- });
244
- }
245
- }
246
- await this.advance(exec, idx + 1, wf);
247
- }
248
- async runParallel(exec, node, idx, wf) {
249
- await executeParallelSteps(node.def.steps, buildContext(exec), exec, this.emitter, this.updateFn);
250
- await this.advance(exec, idx + 1, wf);
251
- }
252
- async runSubWorkflow(exec, node, idx, wf) {
253
- const subInput = node.inputMapper(buildContext(exec));
254
- const recordKey = `sub:${node.name}`;
255
- try {
256
- const { results, executionId } = await executeSubWorkflow(node.name, subInput,
257
- // The child must record who owns it: without this, recovery treats it as a
258
- // top-level run and drives it behind this parent's back.
259
- async (name, input) => {
260
- const handle = await this.start(name, input, exec.id);
261
- // Claim it in the parent's record BEFORE waiting on it. The record used to be
262
- // written only on completion, so a re-entry after a restart found nothing to
263
- // resume and started a second child.
264
- //
265
- // Awaited rather than done on a side branch: a detached `.then()` on a start
266
- // that REJECTS, which is what an unregistered child does, leaves an unhandled
267
- // rejection even though the caller handles the same rejection properly.
268
- exec.steps[recordKey] = { status: 'running', childExecutionId: handle.id };
269
- this.store.update(exec);
270
- return handle;
271
- }, (id) => this.store.get(id), undefined, exec.steps[recordKey]?.childExecutionId);
272
- exec.steps[recordKey] = {
273
- status: 'completed',
274
- result: results,
275
- completedAt: clock().now(),
276
- childExecutionId: executionId,
277
- };
278
- }
279
- catch (error) {
280
- // Settle the record before letting the failure through.
281
- //
282
- // Only the success write existed, so every interesting outcome — the child failed,
283
- // parked in `compensation-stuck`, or timed out — left the record `running`.
284
- // `unwindSet` drops anything that is neither `completed` nor `failed` one line
285
- // before the `sub:` branch can admit it, so `unwindChild` was never called: a
286
- // parent whose child was parked with stock still reserved reversed its own steps,
287
- // reached the end of the pass and reported `rollbackStatus: 'completed'`
288
- // (`test/repro-workflow-child-park-inherit.test.ts`).
289
- //
290
- // It is also the truthful record on its own terms. A `sub:` step left `running`
291
- // after the parent has failed reads on a dashboard as a child still in flight.
292
- const claimed = exec.steps[recordKey];
293
- if (claimed) {
294
- exec.steps[recordKey] = {
295
- ...claimed,
296
- status: 'failed',
297
- error: describeError(error),
298
- completedAt: clock().now(),
299
- };
300
- // Guarded for the same reason as the failure write in `runner.ts`: `error` below
301
- // carries the real cause, and letting a write failure propagate instead replaced
302
- // it. The child's own diagnostic is what the rollback guide's field table points
303
- // an operator at, `... timed out` or `... is parked mid-rollback; resolve it with
304
- // resumeCompensation or abandonCompensation`, so destroying it takes away the
305
- // pointer to the child.
306
- //
307
- // This one does not self-heal, which is what made it worth guarding rather than
308
- // commenting: the next write succeeds, persists the wrong diagnostic, and the run
309
- // goes terminal carrying it
310
- // (`test/repro-workflow-step-error-masking.test.ts`).
311
- try {
312
- this.store.update(exec);
313
- }
314
- catch {
315
- // Deliberately swallowed; the in-memory record still settles the `sub:` step, and
316
- // `runNode` re-persists the whole execution one frame up.
317
- }
318
- }
319
- throw error;
320
- }
321
- await this.advance(exec, idx + 1, wf);
322
- }
323
- /**
324
- * Commit the saga. From here the run can still fail, but it can no longer be
325
- * rolled back: recovery past this point is forward-only.
326
- */
327
- async runPivot(exec, idx, wf) {
328
- exec.committedAt = idx;
329
- this.store.update(exec);
330
- await this.advance(exec, idx + 1, wf);
168
+ listExecutions(wfName, state, options) {
169
+ return this.store.list(wfName, state, options);
331
170
  }
332
- /**
333
- * Run a node body that manages its own step records (loops, forEach, map), then
334
- * move on. These differ only in which executor they delegate to.
335
- */
336
- async runBody(exec, idx, wf, run, def) {
337
- await run(def, exec, this.emitter, this.updateFn);
338
- await this.advance(exec, idx + 1, wf);
171
+ get nodeDeps() {
172
+ return {
173
+ store: this.store,
174
+ emitter: this.emitter,
175
+ updateFn: this.updateFn,
176
+ advance: (exec, nextIdx, wf) => this.advance(exec, nextIdx, wf),
177
+ start: (name, input, parentId) => this.start(name, input, parentId),
178
+ enqueue: (exec) => this.enqueue(exec),
179
+ waitFor: this.waitForDeps,
180
+ };
339
181
  }
340
182
  async advance(exec, nextIdx, wf) {
341
183
  exec.currentNodeIndex = nextIdx;
@@ -386,7 +228,7 @@ export class WorkflowExecutor {
386
228
  }
387
229
  /** Recover orphaned executions after a crash/restart */
388
230
  async recover() {
389
- return recoverExecutions({
231
+ return await recoverExecutions({
390
232
  store: this.store,
391
233
  queue: this.queue,
392
234
  workflows: this.workflows,
@@ -0,0 +1,18 @@
1
+ /** Durable start and signal publication for WorkflowExecutor. */
2
+ import type { Queue } from '../queue/queue';
3
+ import { type TimerHandle } from './clock';
4
+ import type { WorkflowEmitter } from './emitter';
5
+ import type { WorkflowStore } from './store';
6
+ import type { Execution, RunHandle } from './types';
7
+ import type { Workflow } from './workflow';
8
+ interface LifecycleDeps {
9
+ store: WorkflowStore;
10
+ queue: Queue;
11
+ workflows: ReadonlyMap<string, Workflow>;
12
+ emitter: WorkflowEmitter | null;
13
+ timers: Map<string, TimerHandle>;
14
+ enqueue: (exec: Execution) => Promise<void>;
15
+ }
16
+ export declare function startExecution(deps: LifecycleDeps, workflowName: string, input: unknown, parentExecutionId?: string): Promise<RunHandle>;
17
+ export declare function signalExecution(deps: LifecycleDeps, executionId: string, event: string, payload: unknown): Promise<void>;
18
+ export {};
@@ -0,0 +1,69 @@
1
+ /** Durable start and signal publication for WorkflowExecutor. */
2
+ import { clock } from './clock';
3
+ import { unusableEventName } from './workflow';
4
+ import { newExecutionId } from './identity';
5
+ export async function startExecution(deps, workflowName, input, parentExecutionId) {
6
+ const wf = deps.workflows.get(workflowName);
7
+ if (!wf)
8
+ throw new Error(`Workflow "${workflowName}" not registered`);
9
+ if (wf.nodes.length === 0)
10
+ throw new Error(`Workflow "${workflowName}" has no steps`);
11
+ const now = clock().now();
12
+ const id = newExecutionId();
13
+ const exec = {
14
+ id,
15
+ workflowName,
16
+ state: 'running',
17
+ input,
18
+ steps: {},
19
+ currentNodeIndex: 0,
20
+ signals: {},
21
+ definitionHash: wf.seal(),
22
+ ...(parentExecutionId ? { parentExecutionId } : {}),
23
+ createdAt: now,
24
+ updatedAt: now,
25
+ };
26
+ deps.store.save(exec);
27
+ deps.emitter?.emitWorkflow('workflow:started', id, workflowName, 'running', { input });
28
+ try {
29
+ await deps.enqueue(exec);
30
+ }
31
+ catch (error) {
32
+ // start() cannot return an id when publication fails. Removing the row keeps both
33
+ // top-level starts and sub-workflow starts from creating an unreachable orphan.
34
+ deps.store.remove(id);
35
+ throw error;
36
+ }
37
+ return { id, workflowName };
38
+ }
39
+ export async function signalExecution(deps, executionId, event, payload) {
40
+ const bad = unusableEventName(event);
41
+ if (bad)
42
+ throw new Error(`Cannot signal an event ${bad}`);
43
+ // recordSignal owns the read/check/write transaction. In particular it rejects a
44
+ // duplicate key before writing, so concurrent deliveries are first-writer-wins.
45
+ const outcome = deps.store.recordSignal(executionId, event, payload);
46
+ if (!outcome.found)
47
+ throw new Error(`Execution "${executionId}" not found`);
48
+ deps.emitter?.emitSignal('signal:received', executionId, outcome.workflowName, event, payload);
49
+ if (!outcome.resumed)
50
+ return;
51
+ const timer = deps.timers.get(executionId);
52
+ if (timer) {
53
+ clock().clearTimeout(timer);
54
+ deps.timers.delete(executionId);
55
+ }
56
+ try {
57
+ await deps.queue.add('wf:step', {
58
+ executionId,
59
+ workflowName: outcome.workflowName,
60
+ nodeIndex: outcome.currentNodeIndex,
61
+ });
62
+ }
63
+ catch (error) {
64
+ // Preserve the accepted payload but release the publication claim. Recovery sees
65
+ // a waiting execution with its signal present and republishes the same node.
66
+ deps.store.restoreSignalWait(executionId, event, outcome.currentNodeIndex);
67
+ throw error;
68
+ }
69
+ }
@@ -0,0 +1,17 @@
1
+ /** Workflow node dispatch, split from the executor's lifecycle orchestration. */
2
+ import type { WorkflowEmitter } from './emitter';
3
+ import type { WorkflowStore } from './store';
4
+ import type { Execution, RunHandle, WorkflowNode } from './types';
5
+ import { type WaitForDeps } from './waitFor';
6
+ import type { Workflow } from './workflow';
7
+ interface NodeExecutionDeps {
8
+ store: WorkflowStore;
9
+ emitter: WorkflowEmitter | null;
10
+ updateFn: (exec: Execution) => void;
11
+ advance: (exec: Execution, nextIdx: number, workflow: Workflow) => Promise<void>;
12
+ start: (name: string, input: unknown, parentId: string) => Promise<RunHandle>;
13
+ enqueue: (exec: Execution) => Promise<void>;
14
+ waitFor: WaitForDeps;
15
+ }
16
+ export declare function executeWorkflowNode(deps: NodeExecutionDeps, exec: Execution, node: WorkflowNode, idx: number, wf: Workflow): Promise<void>;
17
+ export {};