@cogitator-ai/workflows 0.9.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 (45) hide show
  1. package/README.md +90 -10
  2. package/dist/builder.d.ts +1 -1
  3. package/dist/builder.d.ts.map +1 -1
  4. package/dist/builder.js.map +1 -1
  5. package/dist/executor.d.ts +8 -8
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +83 -8
  8. package/dist/executor.js.map +1 -1
  9. package/dist/human/human-node.d.ts +3 -1
  10. package/dist/human/human-node.d.ts.map +1 -1
  11. package/dist/human/human-node.js +41 -20
  12. package/dist/human/human-node.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/manager/workflow-manager.d.ts +55 -8
  17. package/dist/manager/workflow-manager.d.ts.map +1 -1
  18. package/dist/manager/workflow-manager.js +249 -185
  19. package/dist/manager/workflow-manager.js.map +1 -1
  20. package/dist/nodes/adapters.d.ts +3 -2
  21. package/dist/nodes/adapters.d.ts.map +1 -1
  22. package/dist/nodes/adapters.js +15 -3
  23. package/dist/nodes/adapters.js.map +1 -1
  24. package/dist/nodes/base.d.ts +4 -1
  25. package/dist/nodes/base.d.ts.map +1 -1
  26. package/dist/observability/metrics.d.ts +2 -0
  27. package/dist/observability/metrics.d.ts.map +1 -1
  28. package/dist/observability/metrics.js +4 -0
  29. package/dist/observability/metrics.js.map +1 -1
  30. package/dist/saga/compensation.d.ts +8 -1
  31. package/dist/saga/compensation.d.ts.map +1 -1
  32. package/dist/saga/compensation.js +6 -3
  33. package/dist/saga/compensation.js.map +1 -1
  34. package/dist/saga/index.d.ts +1 -1
  35. package/dist/saga/index.d.ts.map +1 -1
  36. package/dist/saga/index.js.map +1 -1
  37. package/dist/timers/timer-node.d.ts +2 -0
  38. package/dist/timers/timer-node.d.ts.map +1 -1
  39. package/dist/timers/timer-node.js +6 -0
  40. package/dist/timers/timer-node.js.map +1 -1
  41. package/dist/triggers/cron-trigger.d.ts +6 -0
  42. package/dist/triggers/cron-trigger.d.ts.map +1 -1
  43. package/dist/triggers/cron-trigger.js +23 -2
  44. package/dist/triggers/cron-trigger.js.map +1 -1
  45. package/package.json +3 -3
@@ -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,95 +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
- ...this.trackNodes(runId, options),
122
- });
123
- await this.settleNodeUpdates(runId);
124
- const result = timedOut
125
- ? {
126
- ...executed,
127
- error: new Error(`Workflow run '${runId}' timed out after ${String(this.defaultTimeout)}ms`),
128
- }
129
- : executed;
130
- if (result.error) {
131
- await this.runStore.update(runId, {
132
- status: 'failed',
133
- state: result.state,
134
- completedAt: Date.now(),
135
- checkpointId: result.checkpointId,
136
- error: {
137
- name: result.error.name,
138
- message: result.error.message,
139
- stack: result.error.stack,
140
- },
141
- });
142
- const updatedRun = await this.runStore.get(runId);
143
- if (updatedRun)
144
- this.notifyStateChange(updatedRun);
145
- return result;
146
- }
147
- await this.runStore.update(runId, {
148
- status: 'completed',
149
- state: result.state,
150
- output: result.state,
151
- completedAt: Date.now(),
152
- checkpointId: result.checkpointId,
153
- });
154
- const updatedRun = await this.runStore.get(runId);
155
- if (updatedRun)
156
- this.notifyStateChange(updatedRun);
157
- return result;
158
- }
159
- catch (error) {
160
- const err = error instanceof Error ? error : new Error(String(error));
161
- await this.settleNodeUpdates(runId);
162
- await this.runStore.update(runId, {
163
- status: 'failed',
164
- completedAt: Date.now(),
165
- error: {
166
- name: err.name,
167
- message: err.message,
168
- stack: err.stack,
169
- },
170
- });
171
- const updatedRun = await this.runStore.get(runId);
172
- if (updatedRun)
173
- this.notifyStateChange(updatedRun);
174
- throw error;
175
- }
176
- finally {
177
- if (timeoutHandle)
178
- clearTimeout(timeoutHandle);
179
- if (runTracer && runTracer !== this.tracer) {
180
- await runTracer.flush().catch((error) => {
181
- console.warn('[WorkflowManager] Failed to flush run traces:', error);
182
- });
183
- }
184
- this.activeRuns.delete(runId);
185
- this.runLocks.delete(runId);
186
- this.scheduler.runCompleted(runId);
187
- }
147
+ return this.runWorkflow({ runId, workflow, input, options, timeout: this.defaultTimeout });
188
148
  }
189
149
  /**
190
- * 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.
191
152
  */
192
153
  async cancel(runId, reason) {
193
154
  const run = await this.runStore.get(runId);
@@ -201,18 +162,13 @@ export class DefaultWorkflowManager {
201
162
  return;
202
163
  }
203
164
  if (run.status === 'running' || run.status === 'paused') {
204
- const active = this.activeRuns.get(runId);
205
- if (active) {
206
- active.abort();
207
- }
208
- await this.runStore.update(runId, {
165
+ await this.stopRun(runId, 'cancelled', {
209
166
  status: 'cancelled',
210
167
  completedAt: Date.now(),
211
168
  error: reason ? { name: 'CancelError', message: reason } : undefined,
212
169
  });
213
- const updatedRun = await this.runStore.get(runId);
214
- if (updatedRun)
215
- this.notifyStateChange(updatedRun);
170
+ if (!this.activeRuns.has(runId))
171
+ this.runOptions.delete(runId);
216
172
  }
217
173
  }
218
174
  /**
@@ -234,7 +190,9 @@ export class DefaultWorkflowManager {
234
190
  return this.runStore.getStats(workflowName);
235
191
  }
236
192
  /**
237
- * 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`.
238
196
  */
239
197
  async pause(runId) {
240
198
  const run = await this.runStore.get(runId);
@@ -243,35 +201,62 @@ export class DefaultWorkflowManager {
243
201
  if (run.status !== 'running') {
244
202
  throw new Error(`Cannot pause run in status: ${run.status}`);
245
203
  }
246
- const active = this.activeRuns.get(runId);
247
- if (active) {
248
- 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)`);
249
206
  }
250
- await this.runStore.update(runId, {
251
- status: 'paused',
252
- pausedAt: Date.now(),
253
- });
254
- const updatedRun = await this.runStore.get(runId);
255
- if (updatedRun)
256
- this.notifyStateChange(updatedRun);
207
+ await this.stopRun(runId, 'paused', { status: 'paused', pausedAt: Date.now() });
257
208
  }
258
209
  /**
259
- * 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.
260
214
  */
261
- async resume(runId) {
215
+ async resume(runId, options) {
262
216
  const run = await this.runStore.get(runId);
263
217
  if (!run)
264
218
  throw new Error(`Run not found: ${runId}`);
265
219
  if (run.status !== 'paused') {
266
220
  throw new Error(`Cannot resume run in status: ${run.status}`);
267
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
+ }
268
235
  await this.runStore.update(runId, {
269
236
  status: 'running',
270
237
  pausedAt: undefined,
238
+ currentNodes: [],
271
239
  });
272
240
  const updatedRun = await this.runStore.get(runId);
273
241
  if (updatedRun)
274
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(() => { });
275
260
  }
276
261
  /**
277
262
  * Retry a failed run
@@ -306,6 +291,7 @@ export class DefaultWorkflowManager {
306
291
  ...run.metadata,
307
292
  retriedFrom: runId,
308
293
  retriedAt: now,
294
+ retryAttempt: (metadataNumber(run, 'retryAttempt') ?? 0) + 1,
309
295
  },
310
296
  };
311
297
  await this.runStore.save(newRun);
@@ -356,51 +342,21 @@ export class DefaultWorkflowManager {
356
342
  replayedAt: now,
357
343
  },
358
344
  };
345
+ this.registerWorkflow(workflow);
359
346
  await this.runStore.save(newRun);
360
347
  this.notifyStateChange(newRun);
361
- const skipNodes = new Set(newRun.completedNodes);
362
- const abortController = new AbortController();
363
- this.activeRuns.set(newRunId, { abort: () => abortController.abort() });
364
348
  this.scheduler.runStarted(newRunId);
365
- try {
366
- const result = await this.executor.execute(workflow, run.state, {
367
- checkpoint: !!this.checkpointStore,
368
- 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),
369
357
  nodeResults: keptResults,
370
- signal: abortController.signal,
371
- });
372
- await this.runStore.update(newRunId, {
373
- status: result.error ? 'failed' : 'completed',
374
- state: result.state,
375
- output: result.error ? undefined : result.state,
376
- completedAt: Date.now(),
377
- checkpointId: result.checkpointId,
378
- error: result.error
379
- ? { name: result.error.name, message: result.error.message, stack: result.error.stack }
380
- : undefined,
381
- });
382
- const updatedRun = await this.runStore.get(newRunId);
383
- if (updatedRun)
384
- this.notifyStateChange(updatedRun);
385
- return result;
386
- }
387
- catch (error) {
388
- const err = error instanceof Error ? error : new Error(String(error));
389
- await this.runStore.update(newRunId, {
390
- status: 'failed',
391
- completedAt: Date.now(),
392
- error: { name: err.name, message: err.message, stack: err.stack },
393
- });
394
- const updatedRun = await this.runStore.get(newRunId);
395
- if (updatedRun)
396
- this.notifyStateChange(updatedRun);
397
- throw error;
398
- }
399
- finally {
400
- this.activeRuns.delete(newRunId);
401
- this.runLocks.delete(newRunId);
402
- this.scheduler.runCompleted(newRunId);
403
- }
358
+ },
359
+ });
404
360
  }
405
361
  /**
406
362
  * Get count of active runs
@@ -431,15 +387,22 @@ export class DefaultWorkflowManager {
431
387
  dispose() {
432
388
  this.stop();
433
389
  this.activeRuns.clear();
390
+ this.runOptions.clear();
434
391
  this.runLocks.clear();
435
392
  this.workflows.clear();
436
393
  this.stateChangeCallbacks.clear();
437
394
  this.scheduler.dispose();
438
395
  }
396
+ /** Scheduling `timeout` stored on the run, else the manager's `defaultTimeout` */
397
+ runTimeout(run) {
398
+ return metadataNumber(run, 'timeout') ?? this.defaultTimeout;
399
+ }
439
400
  async handleRunReady(runId) {
440
401
  const run = await this.runStore.get(runId);
441
- if (!run)
402
+ if (!run || (run.status !== 'pending' && run.status !== 'scheduled')) {
403
+ this.scheduler.runCompleted(runId);
442
404
  return;
405
+ }
443
406
  const workflow = this.workflows.get(run.workflowName);
444
407
  if (!workflow) {
445
408
  await this.runStore.update(runId, {
@@ -451,53 +414,149 @@ export class DefaultWorkflowManager {
451
414
  },
452
415
  });
453
416
  this.scheduler.runCompleted(runId);
417
+ const failedRun = await this.runStore.get(runId);
418
+ if (failedRun)
419
+ this.notifyStateChange(failedRun);
454
420
  return;
455
421
  }
456
422
  await this.runStore.update(runId, {
457
423
  status: 'running',
458
424
  startedAt: Date.now(),
459
425
  });
460
- const abortController = new AbortController();
461
- this.activeRuns.set(runId, { abort: () => abortController.abort() });
426
+ const startedRun = await this.runStore.get(runId);
427
+ if (startedRun)
428
+ this.notifyStateChange(startedRun);
462
429
  try {
463
- const result = await this.executor.execute(workflow, run.input, {
464
- signal: abortController.signal,
465
- ...this.trackNodes(runId),
430
+ await this.runWorkflow({
431
+ runId,
432
+ workflow,
433
+ input: run.input,
434
+ timeout: this.runTimeout(run),
466
435
  });
467
- await this.settleNodeUpdates(runId);
468
- await this.runStore.update(runId, {
469
- status: result.error ? 'failed' : 'completed',
470
- state: result.state,
471
- output: result.error ? undefined : result.state,
472
- completedAt: Date.now(),
473
- checkpointId: result.checkpointId,
474
- error: result.error
475
- ? { name: result.error.name, message: result.error.message, stack: result.error.stack }
476
- : 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),
477
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;
478
529
  }
479
530
  catch (error) {
480
531
  const err = error instanceof Error ? error : new Error(String(error));
481
532
  await this.settleNodeUpdates(runId);
482
- await this.runStore.update(runId, {
483
- status: 'failed',
484
- completedAt: Date.now(),
485
- error: {
486
- name: err.name,
487
- message: err.message,
488
- stack: err.stack,
489
- },
490
- });
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;
491
538
  }
492
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
+ }
493
547
  this.activeRuns.delete(runId);
494
548
  this.runLocks.delete(runId);
549
+ if (active.stopReason !== 'paused')
550
+ this.runOptions.delete(runId);
495
551
  this.scheduler.runCompleted(runId);
496
- const updatedRun = await this.runStore.get(runId);
497
- if (updatedRun)
498
- this.notifyStateChange(updatedRun);
499
552
  }
500
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
+ }
501
560
  /** Node callbacks that record the run's current, completed and failed nodes, then call `options`' own. */
502
561
  trackNodes(runId, options) {
503
562
  return {
@@ -526,17 +585,22 @@ export class DefaultWorkflowManager {
526
585
  if (!run)
527
586
  return;
528
587
  const updates = {};
588
+ const others = run.currentNodes.filter((n) => n !== nodeId);
529
589
  switch (action) {
530
590
  case 'start':
531
- updates.currentNodes = [...run.currentNodes, nodeId];
591
+ updates.currentNodes = [...others, nodeId];
532
592
  break;
533
593
  case 'complete':
534
- updates.currentNodes = run.currentNodes.filter((n) => n !== nodeId);
535
- updates.completedNodes = [...run.completedNodes, nodeId];
594
+ updates.currentNodes = others;
595
+ updates.completedNodes = run.completedNodes.includes(nodeId)
596
+ ? run.completedNodes
597
+ : [...run.completedNodes, nodeId];
536
598
  break;
537
599
  case 'error':
538
- updates.currentNodes = run.currentNodes.filter((n) => n !== nodeId);
539
- updates.failedNodes = [...run.failedNodes, nodeId];
600
+ updates.currentNodes = others;
601
+ updates.failedNodes = run.failedNodes.includes(nodeId)
602
+ ? run.failedNodes
603
+ : [...run.failedNodes, nodeId];
540
604
  break;
541
605
  }
542
606
  await this.runStore.update(runId, updates);