@multiplier-labs/stepflow 0.3.1 → 0.3.4

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 (112) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -1,4 +1,4 @@
1
- import { W as WorkflowKind } from './types-BVShq7zT.js';
1
+ import { W as WorkflowKind } from './types-K5Gjk3H_.js';
2
2
 
3
3
  /**
4
4
  * Event system types for the workflow engine.
@@ -7,7 +7,7 @@ import { W as WorkflowKind } from './types-BVShq7zT.js';
7
7
  /**
8
8
  * Built-in event types emitted by the workflow engine.
9
9
  */
10
- type BuiltInEventType = "run.created" | "run.queued" | "run.dequeued" | "run.started" | "run.resumed" | "run.completed" | "run.failed" | "run.canceled" | "run.timeout" | "step.started" | "step.completed" | "step.failed" | "step.skipped" | "step.retry";
10
+ type BuiltInEventType = 'run.created' | 'run.queued' | 'run.dequeued' | 'run.started' | 'run.resumed' | 'run.completed' | 'run.failed' | 'run.canceled' | 'run.timeout' | 'step.started' | 'step.completed' | 'step.failed' | 'step.skipped' | 'step.retry';
11
11
  /**
12
12
  * Event types emitted by the workflow engine.
13
13
  * Includes built-in types and allows custom events via string.
@@ -10,18 +10,18 @@ type WorkflowKind = string;
10
10
  /**
11
11
  * Status of a workflow run.
12
12
  */
13
- type RunStatus = "queued" | "running" | "succeeded" | "failed" | "canceled";
13
+ type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
14
14
  /**
15
15
  * Status of a workflow step.
16
16
  */
17
- type StepStatus = "pending" | "running" | "succeeded" | "failed" | "skipped" | "canceled";
17
+ type StepStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'canceled';
18
18
  /**
19
19
  * Error handling strategy for a step.
20
20
  * - 'fail': Stop the workflow immediately
21
21
  * - 'retry': Retry the step up to maxRetries times
22
22
  * - 'skip': Mark as skipped and continue to next step
23
23
  */
24
- type StepErrorStrategy = "fail" | "retry" | "skip";
24
+ type StepErrorStrategy = 'fail' | 'retry' | 'skip';
25
25
  /**
26
26
  * Structured error information stored with runs and steps.
27
27
  */
@@ -35,7 +35,7 @@ interface WorkflowError {
35
35
  * Result of a completed workflow run.
36
36
  */
37
37
  interface RunResult {
38
- status: "succeeded" | "failed" | "canceled";
38
+ status: 'succeeded' | 'failed' | 'canceled';
39
39
  results: Record<string, unknown>;
40
40
  error?: WorkflowError;
41
41
  duration: number;
@@ -496,7 +496,6 @@ await scheduler.addSchedule({
496
496
  await scheduler.removeSchedule(scheduleId);
497
497
  await scheduler.updateSchedule(scheduleId, { enabled: false });
498
498
  const schedules = await scheduler.getSchedules();
499
- const schedule = scheduler.getSchedule(scheduleId);
500
499
  const runId = await scheduler.triggerNow(scheduleId);
501
500
 
502
501
  await scheduler.stop();
@@ -780,7 +779,7 @@ const storage = new SQLiteStorageAdapter({
780
779
  tablePrefix: 'workflow', // default: 'workflow'
781
780
  });
782
781
 
783
- // Additional methods
782
+ // Additional methods (implementation-only; not part of the StorageAdapter interface)
784
783
  storage.transactionSync<T>(fn: () => T): T;
785
784
  await storage.getInterruptedRuns(): Promise<WorkflowRunRecord[]>;
786
785
  await storage.getLastCompletedStep(runId): Promise<WorkflowRunStepRecord | null>;
@@ -868,7 +867,7 @@ await storage.getStepResults(runId): Promise<StepResult[]>;
868
867
  await storage.saveStepResult(result): Promise<void>;
869
868
  ```
870
869
 
871
- **Resume support:**
870
+ **Resume support** (implementation-only; not part of the StorageAdapter interface):
872
871
 
873
872
  ```typescript
874
873
  await storage.getInterruptedRuns(): Promise<WorkflowRunRecord[]>;
@@ -1245,6 +1244,7 @@ import {
1245
1244
  StepTimeoutError,
1246
1245
  WorkflowCanceledError,
1247
1246
  WorkflowTimeoutError,
1247
+ WaitForRunTimeoutError,
1248
1248
  } from '@multiplier-labs/stepflow';
1249
1249
  ```
1250
1250
 
@@ -1258,6 +1258,7 @@ import {
1258
1258
  | `StepTimeoutError` | `STEP_TIMEOUT` | `(stepKey, timeoutMs)` |
1259
1259
  | `WorkflowCanceledError` | `WORKFLOW_CANCELED` | `(runId)` |
1260
1260
  | `WorkflowTimeoutError` | `WORKFLOW_TIMEOUT` | `(runId, timeoutMs)` |
1261
+ | `WaitForRunTimeoutError` | `WAIT_FOR_RUN_TIMEOUT` | `(runId, timeoutMs)` |
1261
1262
 
1262
1263
  ### WorkflowError Record
1263
1264
 
@@ -1423,7 +1424,7 @@ import {
1423
1424
  WorkflowEngineError, WorkflowNotFoundError,
1424
1425
  WorkflowAlreadyRegisteredError, RunNotFoundError,
1425
1426
  StepError, StepTimeoutError,
1426
- WorkflowCanceledError, WorkflowTimeoutError,
1427
+ WorkflowCanceledError, WorkflowTimeoutError, WaitForRunTimeoutError,
1427
1428
  ConsoleLogger, SilentLogger, createScopedLogger,
1428
1429
  generateId, sleep, withRetry, calculateRetryDelay,
1429
1430
  DEFAULT_RETRY_OPTIONS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplier-labs/stepflow",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "A durable, type-safe workflow orchestration engine for Node.js with SQLite persistence, scheduling, and real-time events",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,8 @@
9
9
  "url": "https://github.com/Multiplier-Labs/stepflow"
10
10
  },
11
11
  "publishConfig": {
12
- "registry": "https://npm.pkg.github.com"
12
+ "registry": "https://registry.npmjs.org",
13
+ "access": "public"
13
14
  },
14
15
  "keywords": [
15
16
  "workflow",
@@ -52,11 +53,11 @@
52
53
  },
53
54
  "dependencies": {
54
55
  "cron-parser": "^5.5.0",
55
- "re2": "^1.23.3"
56
+ "re2": "^1.24.0"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "better-sqlite3": ">=11.0.0",
59
- "kysely": ">=0.28.14",
60
+ "kysely": ">=0.28.16",
60
61
  "pg": ">=8.13.0"
61
62
  },
62
63
  "peerDependenciesMeta": {
@@ -74,12 +75,15 @@
74
75
  "@types/better-sqlite3": "^7.6.11",
75
76
  "@types/node": "^22.0.0",
76
77
  "@types/pg": "^8.18.0",
77
- "@vitest/coverage-v8": "^4.1.0",
78
+ "@vitest/coverage-v8": "^4.1.4",
78
79
  "better-sqlite3": "^12.8.0",
79
- "kysely": "^0.28.12",
80
+ "kysely": "^0.28.16",
80
81
  "pg": "^8.20.0",
81
- "tsup": "^8.3.5",
82
+ "tsup": "^8.5.1",
82
83
  "typescript": "^5.7.2",
83
- "vitest": "^4.1.0"
84
+ "vitest": "^4.1.4"
85
+ },
86
+ "overrides": {
87
+ "postcss": ">=8.5.10"
84
88
  }
85
89
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Concurrency control for the WorkflowEngine.
3
+ *
4
+ * Owns:
5
+ * - the priority-ordered queue of pending runs (`runQueue`)
6
+ * - the set of in-flight run abort controllers (`activeRuns`)
7
+ * - the timer handles used to defer launches via setTimeout/setImmediate
8
+ *
9
+ * Responsibilities:
10
+ * - `hasCapacity()` against the configured `maxConcurrency` ceiling
11
+ * - priority-ordered enqueue (higher priority first, FIFO within a priority)
12
+ * - dispatch loop that drains the queue while capacity exists, with a
13
+ * try/catch around each dispatch so a single bad run cannot tear down
14
+ * the loop and strand the rest of the queue (preserves the audit
15
+ * 2026-04-27 C3 fix in `processQueue`).
16
+ * - dispatch-failure telemetry: log + emit `run.failed` + best-effort
17
+ * storage update with `QUEUE_DISPATCH_FAILED`.
18
+ *
19
+ * NOT responsible for:
20
+ * - building the `executeWorkflow` invocation (that lives in WorkflowEngine
21
+ * so the orchestrator/spawnChild wiring stays where it is)
22
+ * - storage initialization / shutdown sequencing (see `lifecycle.ts`)
23
+ */
24
+
25
+ import type { StorageAdapter } from '../../storage/types';
26
+ import type { EventTransport } from '../../events/types';
27
+ import type { Logger, WorkflowDefinition } from '../types';
28
+
29
+ /**
30
+ * Queued run waiting to be executed when capacity is available.
31
+ */
32
+ export interface QueuedRun {
33
+ runId: string;
34
+ definition: WorkflowDefinition;
35
+ input: Record<string, unknown>;
36
+ metadata?: Record<string, unknown>;
37
+ priority: number;
38
+ queuedAt: Date;
39
+ }
40
+
41
+ type TimerHandle = ReturnType<typeof setTimeout> | ReturnType<typeof setImmediate>;
42
+
43
+ export interface ConcurrencyManagerDeps {
44
+ storage: StorageAdapter;
45
+ events: EventTransport;
46
+ logger: Logger;
47
+ /** Maximum concurrent runs; undefined or <=0 means unlimited. */
48
+ maxConcurrency?: number;
49
+ }
50
+
51
+ export class ConcurrencyManager {
52
+ private readonly activeRuns = new Map<string, AbortController>();
53
+ private readonly runQueue: QueuedRun[] = [];
54
+ private readonly timerHandles = new Set<TimerHandle>();
55
+ private readonly storage: StorageAdapter;
56
+ private readonly events: EventTransport;
57
+ private readonly logger: Logger;
58
+ private readonly maxConcurrency: number | undefined;
59
+
60
+ constructor(deps: ConcurrencyManagerDeps) {
61
+ this.storage = deps.storage;
62
+ this.events = deps.events;
63
+ this.logger = deps.logger;
64
+ this.maxConcurrency = deps.maxConcurrency;
65
+ }
66
+
67
+ hasCapacity(): boolean {
68
+ if (this.maxConcurrency === undefined || this.maxConcurrency <= 0) {
69
+ return true;
70
+ }
71
+ return this.activeRuns.size < this.maxConcurrency;
72
+ }
73
+
74
+ getActiveCount(): number {
75
+ return this.activeRuns.size;
76
+ }
77
+
78
+ getQueuedCount(): number {
79
+ return this.runQueue.length;
80
+ }
81
+
82
+ isActive(runId: string): boolean {
83
+ return this.activeRuns.has(runId);
84
+ }
85
+
86
+ /** Insert a run in priority order (higher priority first, FIFO within same). */
87
+ enqueue(run: QueuedRun): void {
88
+ let inserted = false;
89
+ for (let i = 0; i < this.runQueue.length; i++) {
90
+ if (run.priority > this.runQueue[i].priority) {
91
+ this.runQueue.splice(i, 0, run);
92
+ inserted = true;
93
+ break;
94
+ }
95
+ }
96
+ if (!inserted) {
97
+ this.runQueue.push(run);
98
+ }
99
+ }
100
+
101
+ /** Remove a run from the queue if present. Returns true if it was queued. */
102
+ removeFromQueue(runId: string): boolean {
103
+ const idx = this.runQueue.findIndex((q) => q.runId === runId);
104
+ if (idx === -1) return false;
105
+ this.runQueue.splice(idx, 1);
106
+ return true;
107
+ }
108
+
109
+ registerActive(runId: string, controller: AbortController): void {
110
+ this.activeRuns.set(runId, controller);
111
+ }
112
+
113
+ unregisterActive(runId: string): void {
114
+ this.activeRuns.delete(runId);
115
+ }
116
+
117
+ abortActive(runId: string): boolean {
118
+ const controller = this.activeRuns.get(runId);
119
+ if (!controller) return false;
120
+ controller.abort();
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Abort every in-flight run. `onAbort` is called for each run id before its
126
+ * controller is signaled (so callers can log, emit, etc.).
127
+ */
128
+ abortAllActive(onAbort?: (runId: string) => void): void {
129
+ for (const [runId, controller] of this.activeRuns) {
130
+ if (onAbort) onAbort(runId);
131
+ controller.abort();
132
+ }
133
+ }
134
+
135
+ /** Forget all active-run bookkeeping without aborting (used during shutdown). */
136
+ clearActive(): void {
137
+ this.activeRuns.clear();
138
+ }
139
+
140
+ /**
141
+ * Schedule a launch via setTimeout (when `delay` is truthy) or setImmediate
142
+ * (otherwise). Tracks the handle so it can be cleared during shutdown.
143
+ */
144
+ scheduleLaunch(fn: () => void, delay?: number): void {
145
+ if (delay) {
146
+ const handle = setTimeout(fn, delay);
147
+ this.timerHandles.add(handle);
148
+ } else {
149
+ const handle = setImmediate(fn);
150
+ this.timerHandles.add(handle);
151
+ }
152
+ }
153
+
154
+ clearTimers(): void {
155
+ for (const handle of this.timerHandles) {
156
+ if (typeof handle === 'object' && 'ref' in handle) {
157
+ clearImmediate(handle as ReturnType<typeof setImmediate>);
158
+ } else {
159
+ clearTimeout(handle as ReturnType<typeof setTimeout>);
160
+ }
161
+ }
162
+ this.timerHandles.clear();
163
+ }
164
+
165
+ /**
166
+ * Drain the queue while capacity is available, dispatching each item via
167
+ * `dispatch(run)`. Each iteration is wrapped in try/catch so that a failure
168
+ * dispatching one queued item (e.g., an event-transport that throws) cannot
169
+ * tear down the loop and leave remaining items stuck in the queue. Failed
170
+ * dispatches are logged, the run is marked as failed in storage as
171
+ * best-effort telemetry, and the loop advances to the next item.
172
+ *
173
+ * Emits `run.dequeued` for each dispatched run (this is what the C3
174
+ * resilience test exercises by having the emit throw).
175
+ */
176
+ processQueue(dispatch: (run: QueuedRun) => void): void {
177
+ while (this.hasCapacity() && this.runQueue.length > 0) {
178
+ const next = this.runQueue.shift()!;
179
+
180
+ try {
181
+ this.logger.debug(`Starting queued run ${next.runId}`);
182
+
183
+ this.events.emit({
184
+ runId: next.runId,
185
+ kind: next.definition.kind,
186
+ eventType: 'run.dequeued',
187
+ timestamp: new Date(),
188
+ });
189
+
190
+ dispatch(next);
191
+ } catch (err) {
192
+ this.recordQueueDispatchFailure(next, err);
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Record a queue-dispatch failure: log it, emit a failure event, and
199
+ * best-effort mark the run as failed in storage. All side-effects are
200
+ * defensively wrapped so this method itself never throws.
201
+ */
202
+ private recordQueueDispatchFailure(queued: QueuedRun, err: unknown): void {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ this.logger.error(`Failed to dispatch queued run ${queued.runId}`, err);
205
+
206
+ try {
207
+ this.events.emit({
208
+ runId: queued.runId,
209
+ kind: queued.definition.kind,
210
+ eventType: 'run.failed',
211
+ timestamp: new Date(),
212
+ payload: { error: { code: 'QUEUE_DISPATCH_FAILED', message } },
213
+ });
214
+ } catch (emitErr) {
215
+ this.logger.error(`Failed to emit dispatch-failure event for ${queued.runId}`, emitErr);
216
+ }
217
+
218
+ // Fire-and-forget; we don't want to block the queue loop on storage I/O.
219
+ this.storage
220
+ .updateRun(queued.runId, {
221
+ status: 'failed',
222
+ finishedAt: new Date(),
223
+ error: { code: 'QUEUE_DISPATCH_FAILED', message },
224
+ })
225
+ .catch((updateErr) => {
226
+ this.logger.error(`Failed to record dispatch failure for ${queued.runId}`, updateErr);
227
+ });
228
+ }
229
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Lifecycle and resume orchestration for the WorkflowEngine.
3
+ *
4
+ * Owns the cross-cutting "engine itself" lifecycle:
5
+ * - `initialize()` — bring up storage adapters that need explicit init
6
+ * (e.g., PostgresStorageAdapter)
7
+ * - `shutdown()` — drain timers, abort in-flight runs, close transports
8
+ * - `resumeRun()` / `getResumableRuns()` / `resumeAllInterrupted()` —
9
+ * restart interrupted runs from their persisted checkpoints
10
+ *
11
+ * NOT responsible for:
12
+ * - per-step execution (that lives in `orchestrator.ts`)
13
+ * - queue/active/timer bookkeeping (that lives in `engine/concurrency.ts`;
14
+ * this module delegates to a `ConcurrencyManager`)
15
+ * - building the `executeWorkflow` invocation (the engine still owns that
16
+ * so the orchestrator/spawnChild wiring stays in one place; this module
17
+ * calls it via the injected `launchRun` callback)
18
+ */
19
+
20
+ import type { StorageAdapter, WorkflowRunRecord } from '../../storage/types';
21
+ import type { EventTransport } from '../../events/types';
22
+ import type { Logger, WorkflowDefinition, WorkflowKind } from '../types';
23
+ import { RunNotFoundError, WorkflowNotFoundError } from '../../utils/errors';
24
+ import type { ConcurrencyManager } from './concurrency';
25
+
26
+ /**
27
+ * Callback the engine provides for actually launching a run. Lifecycle uses
28
+ * it to start a resumed run with its checkpoint; the engine's implementation
29
+ * registers the abort controller, schedules the launch via the concurrency
30
+ * manager, and invokes the orchestrator.
31
+ */
32
+ export type LaunchRunFn = (
33
+ runId: string,
34
+ definition: WorkflowDefinition,
35
+ input: Record<string, unknown>,
36
+ metadata?: Record<string, unknown>,
37
+ delay?: number,
38
+ checkpoint?: { completedStepKeys: Set<string>; results: Record<string, unknown> }
39
+ ) => void;
40
+
41
+ export interface LifecycleManagerDeps {
42
+ storage: StorageAdapter;
43
+ events: EventTransport;
44
+ logger: Logger;
45
+ registry: Map<WorkflowKind, WorkflowDefinition>;
46
+ concurrency: ConcurrencyManager;
47
+ launchRun: LaunchRunFn;
48
+ }
49
+
50
+ export class LifecycleManager {
51
+ private readonly storage: StorageAdapter;
52
+ private readonly events: EventTransport;
53
+ private readonly logger: Logger;
54
+ private readonly registry: Map<WorkflowKind, WorkflowDefinition>;
55
+ private readonly concurrency: ConcurrencyManager;
56
+ private readonly launchRun: LaunchRunFn;
57
+
58
+ constructor(deps: LifecycleManagerDeps) {
59
+ this.storage = deps.storage;
60
+ this.events = deps.events;
61
+ this.logger = deps.logger;
62
+ this.registry = deps.registry;
63
+ this.concurrency = deps.concurrency;
64
+ this.launchRun = deps.launchRun;
65
+ }
66
+
67
+ /**
68
+ * Initialize the engine's storage adapter if it requires it.
69
+ * Safe to call when the adapter has no `initialize` hook.
70
+ */
71
+ async initialize(): Promise<void> {
72
+ if (this.storage.initialize) {
73
+ await this.storage.initialize();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Shutdown the engine gracefully:
79
+ * 1. clear pending timer handles (so no further launches fire)
80
+ * 2. abort all in-flight runs
81
+ * 3. close the event transport
82
+ * 4. close the storage adapter
83
+ * 5. drop active-run bookkeeping
84
+ */
85
+ async shutdown(): Promise<void> {
86
+ this.logger.info('Shutting down workflow engine...');
87
+
88
+ this.concurrency.clearTimers();
89
+
90
+ this.concurrency.abortAllActive((runId) => {
91
+ this.logger.debug(`Canceling active run: ${runId}`);
92
+ });
93
+
94
+ if (this.events.close) {
95
+ this.events.close();
96
+ }
97
+
98
+ if (this.storage.close) {
99
+ await this.storage.close();
100
+ }
101
+
102
+ this.concurrency.clearActive();
103
+ this.logger.info('Workflow engine shutdown complete');
104
+ }
105
+
106
+ /**
107
+ * Resume an interrupted workflow run from its checkpoint.
108
+ * The run must be in 'queued' or 'running' status.
109
+ */
110
+ async resumeRun(runId: string): Promise<string> {
111
+ const run = await this.storage.getRun(runId);
112
+ if (!run) {
113
+ throw new RunNotFoundError(runId);
114
+ }
115
+
116
+ if (!['queued', 'running'].includes(run.status)) {
117
+ throw new Error(`Cannot resume run ${runId}: status is "${run.status}"`);
118
+ }
119
+
120
+ const definition = this.registry.get(run.kind);
121
+ if (!definition) {
122
+ throw new WorkflowNotFoundError(run.kind);
123
+ }
124
+
125
+ if (this.concurrency.isActive(runId)) {
126
+ this.logger.warn(`Run ${runId} is already active, skipping resume`);
127
+ return runId;
128
+ }
129
+
130
+ this.logger.info(`Resuming run ${runId} from checkpoint`);
131
+
132
+ const completedStepKeys = new Set(run.completedSteps ?? []);
133
+
134
+ this.launchRun(runId, definition, run.input, run.metadata, undefined, {
135
+ completedStepKeys,
136
+ results: run.context,
137
+ });
138
+
139
+ return runId;
140
+ }
141
+
142
+ /**
143
+ * Get all runs that were interrupted and can be resumed.
144
+ * Returns runs with status 'queued' or 'running'.
145
+ */
146
+ async getResumableRuns(): Promise<WorkflowRunRecord[]> {
147
+ const result = await this.storage.listRuns({
148
+ status: ['queued', 'running'],
149
+ });
150
+ return result.items;
151
+ }
152
+
153
+ /**
154
+ * Resume every interrupted run whose workflow kind is currently registered.
155
+ * Useful for recovering after a server restart.
156
+ */
157
+ async resumeAllInterrupted(): Promise<string[]> {
158
+ const resumableRuns = await this.getResumableRuns();
159
+ const resumedIds: string[] = [];
160
+
161
+ for (const run of resumableRuns) {
162
+ if (this.registry.has(run.kind)) {
163
+ try {
164
+ await this.resumeRun(run.id);
165
+ resumedIds.push(run.id);
166
+ } catch (error) {
167
+ this.logger.error(`Failed to resume run ${run.id}:`, error);
168
+ }
169
+ } else {
170
+ this.logger.warn(`Cannot resume run ${run.id}: workflow "${run.kind}" not registered`);
171
+ }
172
+ }
173
+
174
+ return resumedIds;
175
+ }
176
+ }