@cogitator-ai/workflows 0.8.0 → 0.10.0

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 (95) hide show
  1. package/README.md +106 -9
  2. package/dist/builder.d.ts +2 -2
  3. package/dist/builder.d.ts.map +1 -1
  4. package/dist/builder.js.map +1 -1
  5. package/dist/checkpoint-stores.d.ts.map +1 -1
  6. package/dist/checkpoint-stores.js +4 -5
  7. package/dist/checkpoint-stores.js.map +1 -1
  8. package/dist/executor.d.ts +8 -8
  9. package/dist/executor.d.ts.map +1 -1
  10. package/dist/executor.js +83 -8
  11. package/dist/executor.js.map +1 -1
  12. package/dist/human/approval-outcomes.d.ts +26 -0
  13. package/dist/human/approval-outcomes.d.ts.map +1 -0
  14. package/dist/human/approval-outcomes.js +61 -0
  15. package/dist/human/approval-outcomes.js.map +1 -0
  16. package/dist/human/approval-store.d.ts +12 -0
  17. package/dist/human/approval-store.d.ts.map +1 -1
  18. package/dist/human/approval-store.js +52 -9
  19. package/dist/human/approval-store.js.map +1 -1
  20. package/dist/human/durable-approval-stores.d.ts +118 -0
  21. package/dist/human/durable-approval-stores.d.ts.map +1 -0
  22. package/dist/human/durable-approval-stores.js +383 -0
  23. package/dist/human/durable-approval-stores.js.map +1 -0
  24. package/dist/human/human-node.d.ts +16 -8
  25. package/dist/human/human-node.d.ts.map +1 -1
  26. package/dist/human/human-node.js +48 -26
  27. package/dist/human/human-node.js.map +1 -1
  28. package/dist/human/index.d.ts +2 -1
  29. package/dist/human/index.d.ts.map +1 -1
  30. package/dist/human/index.js +1 -0
  31. package/dist/human/index.js.map +1 -1
  32. package/dist/index.d.ts +7 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +4 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/manager/durable-run-stores.d.ts +88 -0
  37. package/dist/manager/durable-run-stores.d.ts.map +1 -0
  38. package/dist/manager/durable-run-stores.js +492 -0
  39. package/dist/manager/durable-run-stores.js.map +1 -0
  40. package/dist/manager/run-store.d.ts +1 -0
  41. package/dist/manager/run-store.d.ts.map +1 -1
  42. package/dist/manager/run-store.js +21 -10
  43. package/dist/manager/run-store.js.map +1 -1
  44. package/dist/manager/workflow-manager.d.ts +59 -8
  45. package/dist/manager/workflow-manager.d.ts.map +1 -1
  46. package/dist/manager/workflow-manager.js +272 -192
  47. package/dist/manager/workflow-manager.js.map +1 -1
  48. package/dist/nodes/adapters.d.ts +3 -2
  49. package/dist/nodes/adapters.d.ts.map +1 -1
  50. package/dist/nodes/adapters.js +15 -3
  51. package/dist/nodes/adapters.js.map +1 -1
  52. package/dist/nodes/base.d.ts +4 -1
  53. package/dist/nodes/base.d.ts.map +1 -1
  54. package/dist/observability/exporters.js +1 -1
  55. package/dist/observability/exporters.js.map +1 -1
  56. package/dist/observability/metrics.d.ts +2 -0
  57. package/dist/observability/metrics.d.ts.map +1 -1
  58. package/dist/observability/metrics.js +4 -0
  59. package/dist/observability/metrics.js.map +1 -1
  60. package/dist/observability/tracer.d.ts +1 -0
  61. package/dist/observability/tracer.d.ts.map +1 -1
  62. package/dist/observability/tracer.js +2 -1
  63. package/dist/observability/tracer.js.map +1 -1
  64. package/dist/postgres-schema.d.ts +18 -0
  65. package/dist/postgres-schema.d.ts.map +1 -0
  66. package/dist/postgres-schema.js +35 -0
  67. package/dist/postgres-schema.js.map +1 -0
  68. package/dist/saga/compensation.d.ts +8 -1
  69. package/dist/saga/compensation.d.ts.map +1 -1
  70. package/dist/saga/compensation.js +6 -3
  71. package/dist/saga/compensation.js.map +1 -1
  72. package/dist/saga/index.d.ts +1 -1
  73. package/dist/saga/index.d.ts.map +1 -1
  74. package/dist/saga/index.js.map +1 -1
  75. package/dist/subworkflows/parallel-subworkflows.d.ts +15 -11
  76. package/dist/subworkflows/parallel-subworkflows.d.ts.map +1 -1
  77. package/dist/subworkflows/parallel-subworkflows.js +21 -31
  78. package/dist/subworkflows/parallel-subworkflows.js.map +1 -1
  79. package/dist/timers/durable-timer-stores.d.ts +174 -0
  80. package/dist/timers/durable-timer-stores.d.ts.map +1 -0
  81. package/dist/timers/durable-timer-stores.js +501 -0
  82. package/dist/timers/durable-timer-stores.js.map +1 -0
  83. package/dist/timers/timer-manager.d.ts +28 -1
  84. package/dist/timers/timer-manager.d.ts.map +1 -1
  85. package/dist/timers/timer-manager.js +87 -3
  86. package/dist/timers/timer-manager.js.map +1 -1
  87. package/dist/timers/timer-node.d.ts +2 -0
  88. package/dist/timers/timer-node.d.ts.map +1 -1
  89. package/dist/timers/timer-node.js +6 -0
  90. package/dist/timers/timer-node.js.map +1 -1
  91. package/dist/triggers/cron-trigger.d.ts +6 -0
  92. package/dist/triggers/cron-trigger.d.ts.map +1 -1
  93. package/dist/triggers/cron-trigger.js +23 -2
  94. package/dist/triggers/cron-trigger.js.map +1 -1
  95. package/package.json +4 -4
@@ -15,6 +15,15 @@ import { WorkflowScheduler } from '../scheduler.js';
15
15
  import { createJobScheduler } from './scheduler.js';
16
16
  import { InMemoryRunStore } from './run-store.js';
17
17
  import { createTracer } from '../observability/tracer.js';
18
+ import { createMetricsCollector } from '../observability/metrics.js';
19
+ /** A finite number stored under `key` in the run's metadata (scheduling options live there) */
20
+ function metadataNumber(run, key) {
21
+ const value = run.metadata?.[key];
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
23
+ }
24
+ function errorRecord(error) {
25
+ return { name: error.name, message: error.message, stack: error.stack };
26
+ }
18
27
  /**
19
28
  * Workflow manager implementation
20
29
  */
@@ -26,11 +35,14 @@ export class DefaultWorkflowManager {
26
35
  executor;
27
36
  workflows = new Map();
28
37
  activeRuns = new Map();
38
+ /** Execute options of runs that are active or paused, reused when a paused run resumes */
39
+ runOptions = new Map();
29
40
  stateChangeCallbacks = new Set();
30
41
  runLocks = new Map();
31
42
  defaultTimeout;
32
43
  tracer;
33
44
  metrics;
45
+ disabledMetrics;
34
46
  constructor(config) {
35
47
  this.cogitator = config.cogitator;
36
48
  this.defaultTimeout = config.defaultTimeout;
@@ -42,14 +54,18 @@ export class DefaultWorkflowManager {
42
54
  this.scheduler = createJobScheduler({
43
55
  runStore: this.runStore,
44
56
  maxConcurrency: config.maxConcurrency,
45
- onRunReady: (runId) => this.handleRunReady(runId),
57
+ onRunReady: (runId) => {
58
+ this.handleRunReady(runId).catch((error) => {
59
+ console.warn(`[WorkflowManager] Scheduled run '${runId}' could not be started:`, error);
60
+ });
61
+ },
46
62
  });
47
63
  if (config.onRunStateChange) {
48
64
  this.stateChangeCallbacks.add(config.onRunStateChange);
49
65
  }
50
66
  }
51
67
  /**
52
- * Start the manager (begins processing scheduled runs)
68
+ * Start the manager (begins processing scheduled runs and cron jobs)
53
69
  */
54
70
  start() {
55
71
  this.scheduler.start();
@@ -61,18 +77,46 @@ export class DefaultWorkflowManager {
61
77
  this.scheduler.stop();
62
78
  }
63
79
  /**
64
- * Register a workflow for scheduling
80
+ * Register a workflow for scheduling, retries and resuming
65
81
  */
66
82
  registerWorkflow(workflow) {
67
83
  this.workflows.set(workflow.name, workflow);
68
84
  }
69
85
  /**
70
- * Schedule a workflow for later execution
86
+ * Schedule a workflow for later execution. `cron` queues one run at the next
87
+ * occurrence; use `registerCronJob` for a recurring schedule.
71
88
  */
72
89
  async schedule(workflow, options) {
73
90
  this.registerWorkflow(workflow);
74
91
  return this.scheduler.scheduleRun(workflow, options);
75
92
  }
93
+ /**
94
+ * Run `workflow` on every occurrence of the cron `expression` while the manager is
95
+ * started. Each occurrence queues a run with `jobOptions` and `triggerId: 'cron:<jobId>'`.
96
+ * Returns the job id.
97
+ */
98
+ registerCronJob(workflow, expression, options) {
99
+ this.registerWorkflow(workflow);
100
+ return this.scheduler.registerCronJob(workflow, expression, options);
101
+ }
102
+ /**
103
+ * Remove a cron job; runs it already queued are kept
104
+ */
105
+ unregisterCronJob(jobId) {
106
+ return this.scheduler.unregisterCronJob(jobId);
107
+ }
108
+ /**
109
+ * Pause or continue a cron job
110
+ */
111
+ setCronJobEnabled(jobId, enabled) {
112
+ return this.scheduler.setCronJobEnabled(jobId, enabled);
113
+ }
114
+ /**
115
+ * List the registered cron jobs
116
+ */
117
+ getCronJobs() {
118
+ return this.scheduler.getCronJobs();
119
+ }
76
120
  /**
77
121
  * Execute a workflow immediately
78
122
  */
@@ -99,104 +143,12 @@ export class DefaultWorkflowManager {
99
143
  };
100
144
  await this.runStore.save(run);
101
145
  this.notifyStateChange(run);
102
- const abortController = new AbortController();
103
- this.activeRuns.set(runId, { abort: () => abortController.abort() });
104
146
  this.scheduler.runStarted(runId);
105
- let timedOut = false;
106
- const timeoutHandle = this.defaultTimeout !== undefined && this.defaultTimeout > 0
107
- ? setTimeout(() => {
108
- timedOut = true;
109
- abortController.abort();
110
- }, this.defaultTimeout)
111
- : undefined;
112
- const runTracer = options?.tracing ? createTracer(options.tracing) : this.tracer;
113
- const runMetrics = options?.metrics?.enabled === false ? undefined : this.metrics;
114
- try {
115
- const executed = await this.executor.execute(workflow, input, {
116
- checkpoint: !!this.checkpointStore,
117
- ...options,
118
- signal: abortController.signal,
119
- tracer: runTracer,
120
- metricsCollector: runMetrics,
121
- onNodeStart: (node) => {
122
- void this.updateRunNodes(runId, node, 'start');
123
- options?.onNodeStart?.(node);
124
- },
125
- onNodeComplete: (node, result, duration) => {
126
- void this.updateRunNodes(runId, node, 'complete');
127
- options?.onNodeComplete?.(node, result, duration);
128
- },
129
- onNodeError: (node, error) => {
130
- void this.updateRunNodes(runId, node, 'error');
131
- options?.onNodeError?.(node, error);
132
- },
133
- });
134
- const result = timedOut
135
- ? {
136
- ...executed,
137
- error: new Error(`Workflow run '${runId}' timed out after ${String(this.defaultTimeout)}ms`),
138
- }
139
- : executed;
140
- if (result.error) {
141
- await this.runStore.update(runId, {
142
- status: 'failed',
143
- state: result.state,
144
- completedAt: Date.now(),
145
- checkpointId: result.checkpointId,
146
- error: {
147
- name: result.error.name,
148
- message: result.error.message,
149
- stack: result.error.stack,
150
- },
151
- });
152
- const updatedRun = await this.runStore.get(runId);
153
- if (updatedRun)
154
- this.notifyStateChange(updatedRun);
155
- return result;
156
- }
157
- await this.runStore.update(runId, {
158
- status: 'completed',
159
- state: result.state,
160
- output: result.state,
161
- completedAt: Date.now(),
162
- checkpointId: result.checkpointId,
163
- });
164
- const updatedRun = await this.runStore.get(runId);
165
- if (updatedRun)
166
- this.notifyStateChange(updatedRun);
167
- return result;
168
- }
169
- catch (error) {
170
- const err = error instanceof Error ? error : new Error(String(error));
171
- await this.runStore.update(runId, {
172
- status: 'failed',
173
- completedAt: Date.now(),
174
- error: {
175
- name: err.name,
176
- message: err.message,
177
- stack: err.stack,
178
- },
179
- });
180
- const updatedRun = await this.runStore.get(runId);
181
- if (updatedRun)
182
- this.notifyStateChange(updatedRun);
183
- throw error;
184
- }
185
- finally {
186
- if (timeoutHandle)
187
- clearTimeout(timeoutHandle);
188
- if (runTracer && runTracer !== this.tracer) {
189
- await runTracer.flush().catch((error) => {
190
- console.warn('[WorkflowManager] Failed to flush run traces:', error);
191
- });
192
- }
193
- this.activeRuns.delete(runId);
194
- this.runLocks.delete(runId);
195
- this.scheduler.runCompleted(runId);
196
- }
147
+ return this.runWorkflow({ runId, workflow, input, options, timeout: this.defaultTimeout });
197
148
  }
198
149
  /**
199
- * Cancel a run
150
+ * Cancel a run. A running run is aborted: the nodes in flight get the abort signal and
151
+ * no further node starts.
200
152
  */
201
153
  async cancel(runId, reason) {
202
154
  const run = await this.runStore.get(runId);
@@ -210,18 +162,13 @@ export class DefaultWorkflowManager {
210
162
  return;
211
163
  }
212
164
  if (run.status === 'running' || run.status === 'paused') {
213
- const active = this.activeRuns.get(runId);
214
- if (active) {
215
- active.abort();
216
- }
217
- await this.runStore.update(runId, {
165
+ await this.stopRun(runId, 'cancelled', {
218
166
  status: 'cancelled',
219
167
  completedAt: Date.now(),
220
168
  error: reason ? { name: 'CancelError', message: reason } : undefined,
221
169
  });
222
- const updatedRun = await this.runStore.get(runId);
223
- if (updatedRun)
224
- this.notifyStateChange(updatedRun);
170
+ if (!this.activeRuns.has(runId))
171
+ this.runOptions.delete(runId);
225
172
  }
226
173
  }
227
174
  /**
@@ -243,7 +190,9 @@ export class DefaultWorkflowManager {
243
190
  return this.runStore.getStats(workflowName);
244
191
  }
245
192
  /**
246
- * Pause a running workflow (aborts execution; resume requires a checkpoint)
193
+ * Pause a running workflow. Execution is aborted (nodes in flight get the abort signal)
194
+ * and `resume()` continues from the run's last checkpoint, so the manager needs a
195
+ * `checkpointStore`.
247
196
  */
248
197
  async pause(runId) {
249
198
  const run = await this.runStore.get(runId);
@@ -252,35 +201,62 @@ export class DefaultWorkflowManager {
252
201
  if (run.status !== 'running') {
253
202
  throw new Error(`Cannot pause run in status: ${run.status}`);
254
203
  }
255
- const active = this.activeRuns.get(runId);
256
- if (active) {
257
- active.abort();
204
+ if (!this.checkpointStore) {
205
+ throw new Error(`Cannot pause run '${runId}': the manager has no checkpointStore to resume it from (use cancel() to stop it)`);
258
206
  }
259
- await this.runStore.update(runId, {
260
- status: 'paused',
261
- pausedAt: Date.now(),
262
- });
263
- const updatedRun = await this.runStore.get(runId);
264
- if (updatedRun)
265
- this.notifyStateChange(updatedRun);
207
+ await this.stopRun(runId, 'paused', { status: 'paused', pausedAt: Date.now() });
266
208
  }
267
209
  /**
268
- * Resume a paused workflow
210
+ * Resume a paused workflow from its last checkpoint (from the start when it was paused
211
+ * before the first checkpoint). Nodes that completed before the checkpoint are not run
212
+ * again. Resolves once the run is running again; follow it with `onRunStateChange` or
213
+ * `getStatus`. `options` default to the ones the run was started with in this process.
269
214
  */
270
- async resume(runId) {
215
+ async resume(runId, options) {
271
216
  const run = await this.runStore.get(runId);
272
217
  if (!run)
273
218
  throw new Error(`Run not found: ${runId}`);
274
219
  if (run.status !== 'paused') {
275
220
  throw new Error(`Cannot resume run in status: ${run.status}`);
276
221
  }
222
+ if (this.activeRuns.has(runId)) {
223
+ throw new Error(`Cannot resume run '${runId}' yet: the nodes it was running when paused have not finished`);
224
+ }
225
+ const workflow = this.workflows.get(run.workflowName);
226
+ if (!workflow) {
227
+ throw new Error(`Workflow not found: ${run.workflowName}`);
228
+ }
229
+ const checkpoint = run.checkpointId
230
+ ? await this.checkpointStore?.load(run.checkpointId)
231
+ : undefined;
232
+ if (run.checkpointId && !checkpoint) {
233
+ throw new Error(`Checkpoint '${run.checkpointId}' of run '${runId}' not found`);
234
+ }
277
235
  await this.runStore.update(runId, {
278
236
  status: 'running',
279
237
  pausedAt: undefined,
238
+ currentNodes: [],
280
239
  });
281
240
  const updatedRun = await this.runStore.get(runId);
282
241
  if (updatedRun)
283
242
  this.notifyStateChange(updatedRun);
243
+ this.scheduler.runStarted(runId);
244
+ void this.runWorkflow({
245
+ runId,
246
+ workflow,
247
+ options: options ?? this.runOptions.get(runId),
248
+ timeout: this.runTimeout(run),
249
+ ...(checkpoint
250
+ ? {
251
+ input: checkpoint.state,
252
+ resumeFrom: {
253
+ workflowId: checkpoint.workflowId,
254
+ skipNodes: new Set(checkpoint.completedNodes),
255
+ nodeResults: checkpoint.nodeResults,
256
+ },
257
+ }
258
+ : { input: run.input }),
259
+ }).catch(() => { });
284
260
  }
285
261
  /**
286
262
  * Retry a failed run
@@ -315,6 +291,7 @@ export class DefaultWorkflowManager {
315
291
  ...run.metadata,
316
292
  retriedFrom: runId,
317
293
  retriedAt: now,
294
+ retryAttempt: (metadataNumber(run, 'retryAttempt') ?? 0) + 1,
318
295
  },
319
296
  };
320
297
  await this.runStore.save(newRun);
@@ -365,51 +342,21 @@ export class DefaultWorkflowManager {
365
342
  replayedAt: now,
366
343
  },
367
344
  };
345
+ this.registerWorkflow(workflow);
368
346
  await this.runStore.save(newRun);
369
347
  this.notifyStateChange(newRun);
370
- const skipNodes = new Set(newRun.completedNodes);
371
- const abortController = new AbortController();
372
- this.activeRuns.set(newRunId, { abort: () => abortController.abort() });
373
348
  this.scheduler.runStarted(newRunId);
374
- try {
375
- const result = await this.executor.execute(workflow, run.state, {
376
- checkpoint: !!this.checkpointStore,
377
- skipNodes,
349
+ return this.runWorkflow({
350
+ runId: newRunId,
351
+ workflow,
352
+ input: run.state,
353
+ timeout: this.defaultTimeout,
354
+ resumeFrom: {
355
+ workflowId: newRunId,
356
+ skipNodes: new Set(kept),
378
357
  nodeResults: keptResults,
379
- signal: abortController.signal,
380
- });
381
- await this.runStore.update(newRunId, {
382
- status: result.error ? 'failed' : 'completed',
383
- state: result.state,
384
- output: result.error ? undefined : result.state,
385
- completedAt: Date.now(),
386
- checkpointId: result.checkpointId,
387
- error: result.error
388
- ? { name: result.error.name, message: result.error.message, stack: result.error.stack }
389
- : undefined,
390
- });
391
- const updatedRun = await this.runStore.get(newRunId);
392
- if (updatedRun)
393
- this.notifyStateChange(updatedRun);
394
- return result;
395
- }
396
- catch (error) {
397
- const err = error instanceof Error ? error : new Error(String(error));
398
- await this.runStore.update(newRunId, {
399
- status: 'failed',
400
- completedAt: Date.now(),
401
- error: { name: err.name, message: err.message, stack: err.stack },
402
- });
403
- const updatedRun = await this.runStore.get(newRunId);
404
- if (updatedRun)
405
- this.notifyStateChange(updatedRun);
406
- throw error;
407
- }
408
- finally {
409
- this.activeRuns.delete(newRunId);
410
- this.runLocks.delete(newRunId);
411
- this.scheduler.runCompleted(newRunId);
412
- }
358
+ },
359
+ });
413
360
  }
414
361
  /**
415
362
  * Get count of active runs
@@ -440,15 +387,22 @@ export class DefaultWorkflowManager {
440
387
  dispose() {
441
388
  this.stop();
442
389
  this.activeRuns.clear();
390
+ this.runOptions.clear();
443
391
  this.runLocks.clear();
444
392
  this.workflows.clear();
445
393
  this.stateChangeCallbacks.clear();
446
394
  this.scheduler.dispose();
447
395
  }
396
+ /** Scheduling `timeout` stored on the run, else the manager's `defaultTimeout` */
397
+ runTimeout(run) {
398
+ return metadataNumber(run, 'timeout') ?? this.defaultTimeout;
399
+ }
448
400
  async handleRunReady(runId) {
449
401
  const run = await this.runStore.get(runId);
450
- if (!run)
402
+ if (!run || (run.status !== 'pending' && run.status !== 'scheduled')) {
403
+ this.scheduler.runCompleted(runId);
451
404
  return;
405
+ }
452
406
  const workflow = this.workflows.get(run.workflowName);
453
407
  if (!workflow) {
454
408
  await this.runStore.update(runId, {
@@ -460,49 +414,170 @@ export class DefaultWorkflowManager {
460
414
  },
461
415
  });
462
416
  this.scheduler.runCompleted(runId);
417
+ const failedRun = await this.runStore.get(runId);
418
+ if (failedRun)
419
+ this.notifyStateChange(failedRun);
463
420
  return;
464
421
  }
465
422
  await this.runStore.update(runId, {
466
423
  status: 'running',
467
424
  startedAt: Date.now(),
468
425
  });
469
- const abortController = new AbortController();
470
- this.activeRuns.set(runId, { abort: () => abortController.abort() });
426
+ const startedRun = await this.runStore.get(runId);
427
+ if (startedRun)
428
+ this.notifyStateChange(startedRun);
471
429
  try {
472
- const result = await this.executor.execute(workflow, run.input, {
473
- signal: abortController.signal,
430
+ await this.runWorkflow({
431
+ runId,
432
+ workflow,
433
+ input: run.input,
434
+ timeout: this.runTimeout(run),
474
435
  });
475
- await this.runStore.update(runId, {
476
- status: result.error ? 'failed' : 'completed',
477
- state: result.state,
478
- output: result.error ? undefined : result.state,
479
- completedAt: Date.now(),
480
- checkpointId: result.checkpointId,
481
- error: result.error
482
- ? { name: result.error.name, message: result.error.message, stack: result.error.stack }
483
- : undefined,
436
+ }
437
+ catch { }
438
+ const finished = await this.runStore.get(runId);
439
+ const maxRetries = finished ? metadataNumber(finished, 'maxRetries') : undefined;
440
+ if (finished?.status === 'failed' &&
441
+ maxRetries !== undefined &&
442
+ (metadataNumber(finished, 'retryAttempt') ?? 0) < maxRetries) {
443
+ await this.retry(runId);
444
+ }
445
+ }
446
+ /**
447
+ * Abort an active run for `reason` and record `update`. The run's own final write waits
448
+ * for this one, so the stop status is never overwritten by a stale 'running' outcome.
449
+ */
450
+ async stopRun(runId, reason, update) {
451
+ const write = this.runStore.update(runId, update);
452
+ const active = this.activeRuns.get(runId);
453
+ if (active) {
454
+ active.stopReason = reason;
455
+ active.stopWrite = write.catch(() => { });
456
+ active.controller.abort();
457
+ }
458
+ await write;
459
+ const updatedRun = await this.runStore.get(runId);
460
+ if (updatedRun)
461
+ this.notifyStateChange(updatedRun);
462
+ }
463
+ /**
464
+ * Execute a run that is already stored as running and counted by the scheduler, then
465
+ * record how it ended: completed, failed, or paused/cancelled when it was stopped.
466
+ */
467
+ async runWorkflow(launch) {
468
+ const { runId, workflow, input, options, timeout, resumeFrom } = launch;
469
+ const active = { controller: new AbortController() };
470
+ this.activeRuns.set(runId, active);
471
+ if (options)
472
+ this.runOptions.set(runId, options);
473
+ let timedOut = false;
474
+ const timeoutHandle = timeout !== undefined && timeout > 0
475
+ ? setTimeout(() => {
476
+ timedOut = true;
477
+ active.controller.abort();
478
+ }, timeout)
479
+ : undefined;
480
+ const runTracer = options?.tracing ? createTracer(options.tracing) : this.tracer;
481
+ const runMetrics = options?.metrics?.enabled === false
482
+ ? (this.disabledMetrics ??= createMetricsCollector({ enabled: false }))
483
+ : this.metrics;
484
+ try {
485
+ const executed = await this.executor.execute(workflow, input, {
486
+ checkpoint: !!this.checkpointStore,
487
+ workflowId: runId,
488
+ ...options,
489
+ ...resumeFrom,
490
+ signal: active.controller.signal,
491
+ tracer: runTracer,
492
+ metricsCollector: runMetrics,
493
+ ...this.trackNodes(runId, options),
484
494
  });
495
+ await this.settleNodeUpdates(runId);
496
+ await active.stopWrite;
497
+ const stopped = executed.error ? active.stopReason : undefined;
498
+ const error = stopped
499
+ ? new Error(`Workflow run '${runId}' was ${stopped}`)
500
+ : timedOut
501
+ ? new Error(`Workflow run '${runId}' timed out after ${String(timeout)}ms`)
502
+ : executed.error;
503
+ const result = error === executed.error ? executed : { ...executed, error };
504
+ const checkpoint = result.checkpointId ? { checkpointId: result.checkpointId } : {};
505
+ if (stopped) {
506
+ await this.finishRun(runId, { status: stopped, state: result.state, ...checkpoint });
507
+ }
508
+ else if (result.error) {
509
+ await this.finishRun(runId, {
510
+ status: 'failed',
511
+ state: result.state,
512
+ completedAt: Date.now(),
513
+ ...checkpoint,
514
+ error: errorRecord(result.error),
515
+ });
516
+ }
517
+ else {
518
+ await this.finishRun(runId, {
519
+ status: 'completed',
520
+ state: result.state,
521
+ output: result.state,
522
+ completedAt: Date.now(),
523
+ pausedAt: undefined,
524
+ error: undefined,
525
+ ...checkpoint,
526
+ });
527
+ }
528
+ return result;
485
529
  }
486
530
  catch (error) {
487
531
  const err = error instanceof Error ? error : new Error(String(error));
488
- await this.runStore.update(runId, {
489
- status: 'failed',
490
- completedAt: Date.now(),
491
- error: {
492
- name: err.name,
493
- message: err.message,
494
- stack: err.stack,
495
- },
496
- });
532
+ await this.settleNodeUpdates(runId);
533
+ await active.stopWrite;
534
+ await this.finishRun(runId, active.stopReason
535
+ ? { status: active.stopReason }
536
+ : { status: 'failed', completedAt: Date.now(), error: errorRecord(err) });
537
+ throw error;
497
538
  }
498
539
  finally {
540
+ if (timeoutHandle)
541
+ clearTimeout(timeoutHandle);
542
+ if (runTracer && runTracer !== this.tracer) {
543
+ await runTracer.flush().catch((error) => {
544
+ console.warn('[WorkflowManager] Failed to flush run traces:', error);
545
+ });
546
+ }
499
547
  this.activeRuns.delete(runId);
548
+ this.runLocks.delete(runId);
549
+ if (active.stopReason !== 'paused')
550
+ this.runOptions.delete(runId);
500
551
  this.scheduler.runCompleted(runId);
501
- const updatedRun = await this.runStore.get(runId);
502
- if (updatedRun)
503
- this.notifyStateChange(updatedRun);
504
552
  }
505
553
  }
554
+ async finishRun(runId, update) {
555
+ await this.runStore.update(runId, update);
556
+ const updatedRun = await this.runStore.get(runId);
557
+ if (updatedRun)
558
+ this.notifyStateChange(updatedRun);
559
+ }
560
+ /** Node callbacks that record the run's current, completed and failed nodes, then call `options`' own. */
561
+ trackNodes(runId, options) {
562
+ return {
563
+ onNodeStart: (node) => {
564
+ void this.updateRunNodes(runId, node, 'start');
565
+ options?.onNodeStart?.(node);
566
+ },
567
+ onNodeComplete: (node, result, duration) => {
568
+ void this.updateRunNodes(runId, node, 'complete');
569
+ options?.onNodeComplete?.(node, result, duration);
570
+ },
571
+ onNodeError: (node, error) => {
572
+ void this.updateRunNodes(runId, node, 'error');
573
+ options?.onNodeError?.(node, error);
574
+ },
575
+ };
576
+ }
577
+ /** Waits for the node updates still being written, so the final run record has them all. */
578
+ async settleNodeUpdates(runId) {
579
+ await this.runLocks.get(runId);
580
+ }
506
581
  async updateRunNodes(runId, nodeId, action) {
507
582
  const prev = this.runLocks.get(runId) ?? Promise.resolve();
508
583
  const next = prev.then(async () => {
@@ -510,17 +585,22 @@ export class DefaultWorkflowManager {
510
585
  if (!run)
511
586
  return;
512
587
  const updates = {};
588
+ const others = run.currentNodes.filter((n) => n !== nodeId);
513
589
  switch (action) {
514
590
  case 'start':
515
- updates.currentNodes = [...run.currentNodes, nodeId];
591
+ updates.currentNodes = [...others, nodeId];
516
592
  break;
517
593
  case 'complete':
518
- updates.currentNodes = run.currentNodes.filter((n) => n !== nodeId);
519
- updates.completedNodes = [...run.completedNodes, nodeId];
594
+ updates.currentNodes = others;
595
+ updates.completedNodes = run.completedNodes.includes(nodeId)
596
+ ? run.completedNodes
597
+ : [...run.completedNodes, nodeId];
520
598
  break;
521
599
  case 'error':
522
- updates.currentNodes = run.currentNodes.filter((n) => n !== nodeId);
523
- updates.failedNodes = [...run.failedNodes, nodeId];
600
+ updates.currentNodes = others;
601
+ updates.failedNodes = run.failedNodes.includes(nodeId)
602
+ ? run.failedNodes
603
+ : [...run.failedNodes, nodeId];
524
604
  break;
525
605
  }
526
606
  await this.runStore.update(runId, updates);