@osolmaz/pi-workflows 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +3 -2
  2. package/dist/builtins/catalog.d.ts +2 -0
  3. package/dist/builtins/catalog.js +22 -0
  4. package/dist/builtins/catalog.js.map +1 -0
  5. package/dist/builtins/monitor.workflow.js +17 -1
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/sqlite.d.ts +27 -4
  8. package/dist/controllers/sqlite.js +83 -5
  9. package/dist/controllers/sqlite.js.map +1 -1
  10. package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
  11. package/dist/controllers/workflow-engine-scheduler.js +3 -1
  12. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  13. package/dist/extension/executor.d.ts +3 -0
  14. package/dist/extension/executor.js +11 -1
  15. package/dist/extension/executor.js.map +1 -1
  16. package/dist/extension/index.js +84 -29
  17. package/dist/extension/index.js.map +1 -1
  18. package/dist/host/runner.d.ts +1 -0
  19. package/dist/host/runner.js +48 -19
  20. package/dist/host/runner.js.map +1 -1
  21. package/dist/workflows/catalog.d.ts +43 -0
  22. package/dist/workflows/catalog.js +79 -0
  23. package/dist/workflows/catalog.js.map +1 -0
  24. package/dist/workflows/engine.d.ts +5 -6
  25. package/dist/workflows/engine.js +71 -33
  26. package/dist/workflows/engine.js.map +1 -1
  27. package/dist/workflows/index.d.ts +2 -2
  28. package/dist/workflows/index.js +1 -1
  29. package/dist/workflows/index.js.map +1 -1
  30. package/dist/workflows/loader.d.ts +18 -16
  31. package/dist/workflows/loader.js +58 -23
  32. package/dist/workflows/loader.js.map +1 -1
  33. package/dist/workflows/migrate-sources.d.ts +41 -0
  34. package/dist/workflows/migrate-sources.js +129 -0
  35. package/dist/workflows/migrate-sources.js.map +1 -0
  36. package/dist/workflows/schema.js +2 -1
  37. package/dist/workflows/schema.js.map +1 -1
  38. package/dist/workflows/store.js +2 -2
  39. package/dist/workflows/store.js.map +1 -1
  40. package/dist/workflows/types.d.ts +18 -4
  41. package/docs/development.md +5 -3
  42. package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
  43. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
  44. package/docs/run-bundles.md +22 -4
  45. package/docs/workflows.md +34 -10
  46. package/package.json +1 -1
  47. package/src/builtins/catalog.ts +22 -0
  48. package/src/builtins/monitor.workflow.ts +25 -1
  49. package/src/controllers/sqlite.ts +128 -10
  50. package/src/controllers/workflow-engine-scheduler.ts +5 -2
  51. package/src/extension/executor.ts +12 -1
  52. package/src/extension/index.ts +106 -37
  53. package/src/host/runner.ts +58 -19
  54. package/src/workflows/catalog.ts +135 -0
  55. package/src/workflows/engine.ts +102 -53
  56. package/src/workflows/index.ts +2 -0
  57. package/src/workflows/loader.ts +70 -26
  58. package/src/workflows/migrate-sources.ts +167 -0
  59. package/src/workflows/schema.ts +2 -1
  60. package/src/workflows/store.ts +2 -2
  61. package/src/workflows/types.ts +13 -4
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
4
5
  import {
5
6
  ControllerManager,
6
7
  loadDiscoveredControllers,
@@ -17,8 +18,9 @@ import {
17
18
  isClaimLostError,
18
19
  WorkflowSourceChangedError,
19
20
  } from "../workflows/errors.js";
20
- import { hashWorkflowSource, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
21
- import { WorkflowRunStore } from "../workflows/store.js";
21
+ import { resolveWorkflowRef, resolveWorkflowSource } from "../workflows/loader.js";
22
+ import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
23
+ import { WorkflowRunStore, readRunBundle } from "../workflows/store.js";
22
24
  import type { WorkflowDefinition } from "../workflows/types.js";
23
25
  import { HostProcessRegistry } from "./processes.js";
24
26
  import { RpcStepExecutor } from "./rpc-executor.js";
@@ -59,6 +61,7 @@ export class WorkflowHost {
59
61
  private manager: ControllerManager | null = null;
60
62
  private pollTimer: ReturnType<typeof setInterval> | null = null;
61
63
  private readonly activeRuns = new Map<string, Promise<void>>();
64
+ private readonly migrationBlockedRuns = new Set<string>();
62
65
  private readonly schedulerExecutors = new Map<WorkflowEngine, RpcStepExecutor>();
63
66
  /** Runs whose resume refused (edited source); skipped until a host restart. */
64
67
  private readonly skippedRuns = new Set<string>();
@@ -94,14 +97,27 @@ export class WorkflowHost {
94
97
  this.log(`reaped ${reaped.length} orphaned headless session(s): ${reaped.join(", ")}`);
95
98
  }
96
99
 
100
+ const migration = await migrateLegacyWorkflowSources({
101
+ catalog: builtinWorkflowCatalog,
102
+ store: this.childRunStore,
103
+ queue: this.store,
104
+ });
105
+ for (const blocked of migration.blocked) {
106
+ this.migrationBlockedRuns.add(blocked.runId);
107
+ this.log(`run ${blocked.runId} parked: ${blocked.reason}`);
108
+ }
109
+
97
110
  const definitions = await loadDiscoveredControllers({ cwd: this.options.cwd });
98
111
  if (definitions.length > 0) {
99
112
  const scheduler = new WorkflowEngineScheduler({
100
113
  store: this.childRunStore,
101
114
  resolveWorkflow: async (name) => {
102
- const resolved = await resolveWorkflowRef(name, { cwd: this.options.cwd });
103
- const workflow = await loadWorkflowFile(resolved.path);
104
- return { workflow };
115
+ const resolved = await resolveWorkflowRef(
116
+ name,
117
+ { cwd: this.options.cwd },
118
+ builtinWorkflowCatalog,
119
+ );
120
+ return { workflow: resolved.definition, workflowSource: resolved.source };
105
121
  },
106
122
  createEngine: () => {
107
123
  const executor = new RpcStepExecutor({
@@ -176,7 +192,7 @@ export class WorkflowHost {
176
192
  runnerId: this.runnerId,
177
193
  claimToken: randomUUID(),
178
194
  leaseMs: RUN_CLAIM_LEASE_MS,
179
- excludeRunIds: [...this.skippedRuns],
195
+ excludeRunIds: [...this.skippedRuns, ...this.migrationBlockedRuns],
180
196
  });
181
197
  } catch (error) {
182
198
  // Store contention or corruption must not kill the host's loop.
@@ -195,13 +211,36 @@ export class WorkflowHost {
195
211
  private async runClaimed(record: WorkflowRunQueueRecord): Promise<void> {
196
212
  const claimToken = record.claimToken as string;
197
213
  const runId = record.runId;
198
- this.log(`resuming ${record.workflowRef} run ${runId}`);
214
+ this.log(`resuming ${record.workflowName} run ${runId}`);
199
215
  let workflow: WorkflowDefinition;
200
- let workflowHash: string;
216
+ let workflowSource: import("../workflows/types.js").WorkflowSource;
201
217
  try {
202
- workflow = await loadWorkflowFile(record.workflowPath);
203
- workflowHash = await hashWorkflowSource(record.workflowPath);
218
+ const bundle = await readRunBundle(this.childRunStore.runDirFor(runId));
219
+ if (bundle?.state.workflowSource === undefined) {
220
+ throw new Error(`Workflow run ${runId} has no canonical workflow source`);
221
+ }
222
+ workflow = await resolveWorkflowSource(
223
+ bundle.state.workflowSource,
224
+ builtinWorkflowCatalog,
225
+ runId,
226
+ );
227
+ workflowSource = bundle.state.workflowSource;
204
228
  } catch (error) {
229
+ if (error instanceof WorkflowSourceChangedError) {
230
+ try {
231
+ this.store.parkWorkflowRun({ runId, claimToken });
232
+ } catch {
233
+ // Best-effort.
234
+ }
235
+ this.skippedRuns.add(runId);
236
+ this.recordEvent(runId, record.workflowName, "parked", {
237
+ reason: "workflow source changed",
238
+ });
239
+ this.log(
240
+ `run ${runId} skipped: workflow source changed; install the matching package revision, then restart the host`,
241
+ );
242
+ return;
243
+ }
205
244
  await this.failUnresumable(record, claimToken, errorMessage(error));
206
245
  return;
207
246
  }
@@ -245,23 +284,23 @@ export class WorkflowHost {
245
284
  }, RUN_CLAIM_RENEW_MS);
246
285
  renewTimer.unref?.();
247
286
 
248
- this.recordEvent(runId, record.workflowRef, "resumed", { runnerId: this.runnerId });
287
+ this.recordEvent(runId, record.workflowName, "resumed", { runnerId: this.runnerId });
249
288
  try {
250
- const result = await engine.resumeRun(workflow, runId, { workflowHash });
289
+ const result = await engine.resumeRun(workflow, runId, { workflowSource });
251
290
  clearInterval(renewTimer);
252
291
  if (result.state.status === "running") {
253
292
  // Parked again mid-drain: leave it claimable for the next runner.
254
293
  this.store.parkWorkflowRun({ runId, claimToken });
255
- this.recordEvent(runId, record.workflowRef, "parked", {});
256
- this.log(`parked ${record.workflowRef} run ${runId}`);
294
+ this.recordEvent(runId, record.workflowName, "parked", {});
295
+ this.log(`parked ${record.workflowName} run ${runId}`);
257
296
  return;
258
297
  }
259
298
  this.store.completeWorkflowRun({ runId, claimToken });
260
- this.recordEvent(runId, record.workflowRef, result.state.status, {
299
+ this.recordEvent(runId, record.workflowName, result.state.status, {
261
300
  ...(result.state.error !== undefined ? { error: result.state.error } : {}),
262
301
  ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
263
302
  });
264
- this.log(`${record.workflowRef} run ${runId} ${result.state.status}`);
303
+ this.log(`${record.workflowName} run ${runId} ${result.state.status}`);
265
304
  } catch (error) {
266
305
  clearInterval(renewTimer);
267
306
  if (isClaimLostError(error)) {
@@ -277,7 +316,7 @@ export class WorkflowHost {
277
316
  // Best-effort.
278
317
  }
279
318
  this.skippedRuns.add(runId);
280
- this.recordEvent(runId, record.workflowRef, "parked", {
319
+ this.recordEvent(runId, record.workflowName, "parked", {
281
320
  reason: "workflow source changed",
282
321
  });
283
322
  this.log(
@@ -327,9 +366,9 @@ export class WorkflowHost {
327
366
  // no-op (the bundle was already waiting or completed), so the feed
328
367
  // stays truthful for sessions syncing from it.
329
368
  if (actualStatus !== undefined && actualStatus !== "failed") {
330
- this.recordEvent(record.runId, record.workflowRef, actualStatus, {});
369
+ this.recordEvent(record.runId, record.workflowName, actualStatus, {});
331
370
  } else {
332
- this.recordEvent(record.runId, record.workflowRef, "failed", { error: message });
371
+ this.recordEvent(record.runId, record.workflowName, "failed", { error: message });
333
372
  }
334
373
  this.log(`run ${record.runId} cannot resume: ${message}`);
335
374
  }
@@ -0,0 +1,135 @@
1
+ import { isWorkflowDefinition } from "./definition.js";
2
+ import { WorkflowSourceChangedError } from "./errors.js";
3
+ import type { WorkflowDefinition, WorkflowSource } from "./types.js";
4
+
5
+ const BUILTIN_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
6
+ const REVISION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+
8
+ export type LegacyBuiltinSource = {
9
+ workflowHash: string;
10
+ revision: string;
11
+ pathSuffixes: readonly string[];
12
+ };
13
+
14
+ export type BuiltinWorkflowRegistration = {
15
+ id: string;
16
+ revision: string;
17
+ definition: WorkflowDefinition;
18
+ legacySources?: LegacyBuiltinSource[];
19
+ };
20
+
21
+ export type BuiltinWorkflowEntry = Readonly<{
22
+ id: string;
23
+ ref: string;
24
+ revision: string;
25
+ definition: WorkflowDefinition;
26
+ legacySources: readonly LegacyBuiltinSource[];
27
+ }>;
28
+
29
+ export type LegacyBuiltinMatch = {
30
+ entry: BuiltinWorkflowEntry;
31
+ revision: string;
32
+ };
33
+
34
+ /** Process-local catalog of package-provided workflow definitions. */
35
+ export class BuiltinWorkflowCatalog {
36
+ private readonly byId = new Map<string, BuiltinWorkflowEntry>();
37
+ private readonly byName = new Map<string, BuiltinWorkflowEntry>();
38
+
39
+ constructor(registrations: BuiltinWorkflowRegistration[]) {
40
+ for (const registration of registrations) {
41
+ if (!BUILTIN_ID_PATTERN.test(registration.id)) {
42
+ throw new Error(`Invalid built-in workflow id: ${JSON.stringify(registration.id)}`);
43
+ }
44
+ if (!REVISION_PATTERN.test(registration.revision)) {
45
+ throw new Error(
46
+ `Invalid built-in workflow revision: ${JSON.stringify(registration.revision)}`,
47
+ );
48
+ }
49
+ if (!isWorkflowDefinition(registration.definition)) {
50
+ throw new Error(`Built-in workflow ${registration.id} is not defined with defineWorkflow`);
51
+ }
52
+ if (this.byId.has(registration.id)) {
53
+ throw new Error(`Duplicate built-in workflow id: ${registration.id}`);
54
+ }
55
+ if (this.byName.has(registration.definition.name)) {
56
+ throw new Error(`Duplicate built-in workflow name: ${registration.definition.name}`);
57
+ }
58
+ const entry: BuiltinWorkflowEntry = Object.freeze({
59
+ id: registration.id,
60
+ ref: `builtin:${registration.id}`,
61
+ revision: registration.revision,
62
+ definition: registration.definition,
63
+ legacySources: Object.freeze(
64
+ (registration.legacySources ?? []).map((legacy) =>
65
+ Object.freeze({ ...legacy, pathSuffixes: Object.freeze([...legacy.pathSuffixes]) }),
66
+ ),
67
+ ),
68
+ });
69
+ this.byId.set(entry.id, entry);
70
+ this.byName.set(entry.definition.name, entry);
71
+ }
72
+ }
73
+
74
+ list(): BuiltinWorkflowEntry[] {
75
+ return [...this.byId.values()];
76
+ }
77
+
78
+ get(id: string): BuiltinWorkflowEntry | undefined {
79
+ return this.byId.get(id);
80
+ }
81
+
82
+ getByName(name: string): BuiltinWorkflowEntry | undefined {
83
+ return this.byName.get(name);
84
+ }
85
+
86
+ resolve(
87
+ source: WorkflowSource,
88
+ runId = `builtin:${source.kind === "builtin" ? source.id : "unknown"}`,
89
+ ): WorkflowDefinition {
90
+ if (source.kind !== "builtin") {
91
+ throw new Error("A file workflow source cannot be resolved by the built-in catalog");
92
+ }
93
+ const entry = this.byId.get(source.id);
94
+ if (entry === undefined) {
95
+ throw new Error(`Unknown built-in workflow: ${source.id}`);
96
+ }
97
+ if (entry.revision !== source.revision) {
98
+ throw new WorkflowSourceChangedError(runId);
99
+ }
100
+ return entry.definition;
101
+ }
102
+
103
+ matchLegacy(options: {
104
+ workflowName: string;
105
+ workflowPath: string;
106
+ workflowHash: string;
107
+ }): LegacyBuiltinMatch | undefined {
108
+ const entry = this.legacyPathEntry(options);
109
+ if (entry === undefined) return undefined;
110
+ const workflowPath = options.workflowPath.replaceAll("\\", "/");
111
+ const legacy = entry.legacySources.find(
112
+ (candidate) =>
113
+ candidate.workflowHash === options.workflowHash &&
114
+ candidate.pathSuffixes.some((suffix) =>
115
+ workflowPath.endsWith(suffix.replaceAll("\\", "/")),
116
+ ),
117
+ );
118
+ return legacy === undefined ? undefined : { entry, revision: legacy.revision };
119
+ }
120
+
121
+ /** Identify a registered old built-in path without accepting its revision. */
122
+ legacyPathEntry(options: {
123
+ workflowName: string;
124
+ workflowPath: string;
125
+ }): BuiltinWorkflowEntry | undefined {
126
+ const entry = this.byName.get(options.workflowName);
127
+ if (entry === undefined) return undefined;
128
+ const workflowPath = options.workflowPath.replaceAll("\\", "/");
129
+ return entry.legacySources.some((legacy) =>
130
+ legacy.pathSuffixes.some((suffix) => workflowPath.endsWith(suffix.replaceAll("\\", "/"))),
131
+ )
132
+ ? entry
133
+ : undefined;
134
+ }
135
+ }
@@ -32,6 +32,7 @@ import type {
32
32
  WorkflowNodeResult,
33
33
  WorkflowRunResult,
34
34
  WorkflowRunState,
35
+ WorkflowSource,
35
36
  WorkflowStepRecord,
36
37
  WorkflowTraceEventDraft,
37
38
  } from "./types.js";
@@ -39,6 +40,7 @@ import type {
39
40
  const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
40
41
  const DEFAULT_MAX_STEPS = 100;
41
42
  const TITLE_TIMEOUT_MS = 30_000;
43
+ const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
42
44
  // Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
43
45
  const ABORT_CLEANUP_GRACE_MS = 2_000;
44
46
 
@@ -140,7 +142,7 @@ export class WorkflowEngine {
140
142
  async run(
141
143
  workflow: WorkflowDefinition,
142
144
  input: unknown,
143
- options: { workflowPath?: string; workflowHash?: string; runId?: string } = {},
145
+ options: { workflowSource?: WorkflowSource; runId?: string } = {},
144
146
  ): Promise<WorkflowRunResult> {
145
147
  validateWorkflowDefinition(workflow);
146
148
  // Fail before any bundle exists so bad input cannot leave a partial run
@@ -157,8 +159,7 @@ export class WorkflowEngine {
157
159
  const state = await this.createRunState(
158
160
  workflow,
159
161
  normalizedInput,
160
- options.workflowPath,
161
- options.workflowHash,
162
+ options.workflowSource,
162
163
  options.runId,
163
164
  );
164
165
  const runDir = await this.store.initializeRunBundle(workflow, state);
@@ -196,7 +197,7 @@ export class WorkflowEngine {
196
197
  async resumeRun(
197
198
  workflow: WorkflowDefinition,
198
199
  runId: string,
199
- options: { workflowHash?: string; force?: boolean } = {},
200
+ options: { workflowSource?: WorkflowSource; force?: boolean } = {},
200
201
  ): Promise<WorkflowRunResult> {
201
202
  validateWorkflowDefinition(workflow);
202
203
  // Reset before any await: a park or cancel landing during preparation
@@ -207,11 +208,8 @@ export class WorkflowEngine {
207
208
  const bundle = await this.store.prepareRunResume(runId);
208
209
  const { runDir } = bundle;
209
210
  const state = bundle.state;
210
- const hashMismatch =
211
- state.workflowHash !== undefined &&
212
- options.workflowHash !== undefined &&
213
- state.workflowHash !== options.workflowHash;
214
- if (hashMismatch && options.force !== true) {
211
+ const sourceMismatch = workflowSourceMismatch(state, options.workflowSource);
212
+ if (sourceMismatch && options.force !== true) {
215
213
  throw new WorkflowSourceChangedError(runId);
216
214
  }
217
215
 
@@ -230,7 +228,7 @@ export class WorkflowEngine {
230
228
  payload: {
231
229
  ...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
232
230
  replayedSteps: state.steps.length,
233
- ...(hashMismatch ? { workflowHashMismatch: true, forced: true } : {}),
231
+ ...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
234
232
  },
235
233
  });
236
234
  await this.onRunStarted?.(runDir, state);
@@ -283,7 +281,7 @@ export class WorkflowEngine {
283
281
  workflow: WorkflowDefinition,
284
282
  parentRunId: string,
285
283
  input: unknown,
286
- options: { workflowPath?: string; workflowHash?: string; runId?: string; force?: boolean } = {},
284
+ options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
287
285
  ): Promise<WorkflowRunResult> {
288
286
  validateWorkflowDefinition(workflow);
289
287
  this.cancelled = false;
@@ -298,11 +296,8 @@ export class WorkflowEngine {
298
296
  `Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
299
297
  );
300
298
  }
301
- const hashMismatch =
302
- parent.state.workflowHash !== undefined &&
303
- options.workflowHash !== undefined &&
304
- parent.state.workflowHash !== options.workflowHash;
305
- if (hashMismatch && options.force !== true) {
299
+ const sourceMismatch = workflowSourceMismatch(parent.state, options.workflowSource);
300
+ if (sourceMismatch && options.force !== true) {
306
301
  throw new WorkflowSourceChangedError(parentRunId);
307
302
  }
308
303
 
@@ -315,8 +310,7 @@ export class WorkflowEngine {
315
310
  const state = await this.createRunState(
316
311
  workflow,
317
312
  normalizedInput,
318
- options.workflowPath,
319
- options.workflowHash,
313
+ options.workflowSource,
320
314
  options.runId,
321
315
  );
322
316
  state.parentRunId = parentRunId;
@@ -477,8 +471,7 @@ export class WorkflowEngine {
477
471
  private async createRunState(
478
472
  workflow: WorkflowDefinition,
479
473
  input: unknown,
480
- workflowPath: string | undefined,
481
- workflowHash: string | undefined,
474
+ workflowSource: WorkflowSource | undefined,
482
475
  runId: string | undefined,
483
476
  ): Promise<WorkflowRunState> {
484
477
  const now = new Date().toISOString();
@@ -488,8 +481,7 @@ export class WorkflowEngine {
488
481
  runId: runId ?? createRunId(workflow.name),
489
482
  workflowName: workflow.name,
490
483
  ...(await this.resolveTitleBounded(workflow, input)),
491
- ...(workflowPath !== undefined ? { workflowPath } : {}),
492
- ...(workflowHash !== undefined ? { workflowHash } : {}),
484
+ ...(workflowSource !== undefined ? { workflowSource } : {}),
493
485
  startedAt: now,
494
486
  updatedAt: now,
495
487
  status: "running",
@@ -758,36 +750,43 @@ export class WorkflowEngine {
758
750
  node: WorkflowNodeDefinition,
759
751
  meta: NodeExecutionMeta,
760
752
  ): Promise<NodeExecution> {
761
- const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
762
753
  const abort = new AbortController();
754
+ const context = this.createNodeContext(state, abort.signal);
755
+ let timer: NodeJS.Timeout | undefined;
756
+ let dispatchSettled: Promise<void> | undefined;
763
757
  this.activeAbort = abort;
764
- if (this.parked) {
765
- // A park that landed during the node_started persist must not let the
766
- // node dispatch: its discarded side effects would rerun on resume.
767
- throw new RunParkedError();
768
- }
769
- if (this.cancelled) {
770
- throw new CancelledError();
771
- }
772
-
773
- const timer = setTimeout(() => {
774
- abort.abort(new TimeoutError(timeoutMs));
775
- }, timeoutMs);
776
- const dispatched = this.dispatchNode(
777
- workflow,
778
- state,
779
- runDir,
780
- nodeId,
781
- attemptId,
782
- node,
783
- abort.signal,
784
- meta,
785
- );
786
- const dispatchSettled = dispatched.then(
787
- () => undefined,
788
- () => undefined,
789
- );
790
758
  try {
759
+ if (this.parked) {
760
+ // A park that landed during the node_started persist must not let the
761
+ // node dispatch: its discarded side effects would rerun on resume.
762
+ throw new RunParkedError();
763
+ }
764
+ if (this.cancelled) {
765
+ throw new CancelledError();
766
+ }
767
+
768
+ const timeoutMs = await this.resolveNodeTimeout(node, context, abort);
769
+ if (abort.signal.aborted) {
770
+ throw abortError(abort.signal);
771
+ }
772
+ timer = setTimeout(() => {
773
+ abort.abort(new TimeoutError(timeoutMs));
774
+ }, timeoutMs);
775
+ const dispatched = this.dispatchNode(
776
+ workflow,
777
+ state,
778
+ runDir,
779
+ nodeId,
780
+ attemptId,
781
+ node,
782
+ context,
783
+ abort.signal,
784
+ meta,
785
+ );
786
+ dispatchSettled = dispatched.then(
787
+ () => undefined,
788
+ () => undefined,
789
+ );
791
790
  // Race the dispatch against the abort signal so timeouts and cancel
792
791
  // take effect even for node callbacks that never observe the signal.
793
792
  const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
@@ -799,7 +798,7 @@ export class WorkflowEngine {
799
798
  assertJsonSerializable(execution.output, `Node ${nodeId} output`);
800
799
  return execution;
801
800
  } catch (error) {
802
- if (node.nodeType === "action" && "exec" in node) {
801
+ if (node.nodeType === "action" && "exec" in node && dispatchSettled !== undefined) {
803
802
  // Give the killed shell command a short grace period to close so its
804
803
  // action receipt lands in `meta` before the failed attempt persists.
805
804
  await Promise.race([
@@ -809,9 +808,37 @@ export class WorkflowEngine {
809
808
  }
810
809
  const reason: unknown = abort.signal.aborted ? abort.signal.reason : undefined;
811
810
  throw reason instanceof TimeoutError || reason instanceof CancelledError ? reason : error;
811
+ } finally {
812
+ if (timer !== undefined) {
813
+ clearTimeout(timer);
814
+ }
815
+ if (this.activeAbort === abort) {
816
+ this.activeAbort = null;
817
+ }
818
+ }
819
+ }
820
+
821
+ private async resolveNodeTimeout(
822
+ node: WorkflowNodeDefinition,
823
+ context: WorkflowNodeContext,
824
+ abort: AbortController,
825
+ ): Promise<number> {
826
+ const configured = node.timeoutMs;
827
+ if (typeof configured !== "function") {
828
+ return assertValidTimeout(configured ?? this.defaultNodeTimeoutMs);
829
+ }
830
+ const timer = setTimeout(
831
+ () => abort.abort(new TimeoutError(TIMEOUT_RESOLUTION_TIMEOUT_MS)),
832
+ TIMEOUT_RESOLUTION_TIMEOUT_MS,
833
+ );
834
+ try {
835
+ const resolved = await Promise.race([
836
+ Promise.resolve(configured(context)),
837
+ abortRejection(abort.signal),
838
+ ]);
839
+ return assertValidTimeout(resolved);
812
840
  } finally {
813
841
  clearTimeout(timer);
814
- this.activeAbort = null;
815
842
  }
816
843
  }
817
844
 
@@ -822,10 +849,10 @@ export class WorkflowEngine {
822
849
  nodeId: string,
823
850
  attemptId: string,
824
851
  node: WorkflowNodeDefinition,
852
+ context: WorkflowNodeContext,
825
853
  signal: AbortSignal,
826
854
  meta: NodeExecutionMeta,
827
855
  ): Promise<NodeExecution> {
828
- const context = this.createNodeContext(state, signal);
829
856
  switch (node.nodeType) {
830
857
  case "agent":
831
858
  return await this.runAgentNode(
@@ -1044,7 +1071,13 @@ async function runShellActionNode(
1044
1071
  return { output, promptText: null, action: shellReceipt(result) };
1045
1072
  }
1046
1073
 
1047
- /** Rejects with the abort reason once the signal fires; never resolves. */
1074
+ function assertValidTimeout(value: number): number {
1075
+ if (!Number.isFinite(value) || value <= 0) {
1076
+ throw new Error("Node timeoutMs must resolve to a finite positive number");
1077
+ }
1078
+ return value;
1079
+ }
1080
+
1048
1081
  /** The error carried by an aborted signal, normalized to an Error. */
1049
1082
  function abortError(signal: AbortSignal): Error {
1050
1083
  const reason: unknown = signal.reason ?? new CancelledError();
@@ -1069,6 +1102,22 @@ function abortRejection(signal: AbortSignal): Promise<never> {
1069
1102
  * Failing here turns a bad callback return value into a normal node failure
1070
1103
  * instead of corrupting the run state.
1071
1104
  */
1105
+ function workflowSourceMismatch(
1106
+ state: WorkflowRunState,
1107
+ source: WorkflowSource | undefined,
1108
+ ): boolean {
1109
+ if (source === undefined) return false;
1110
+ if (state.workflowSource !== undefined) {
1111
+ return !isDeepStrictEqual(state.workflowSource, source);
1112
+ }
1113
+ // Bounded compatibility check for pre-catalog file runs. Startup normally
1114
+ // converts these records with migrateLegacyWorkflowSources first.
1115
+ return (
1116
+ state.workflowHash !== undefined &&
1117
+ (source.kind !== "file" || state.workflowHash !== source.hash)
1118
+ );
1119
+ }
1120
+
1072
1121
  function assertJsonSerializable(value: unknown, what: string): void {
1073
1122
  let encoded: string | undefined;
1074
1123
  try {
@@ -21,6 +21,7 @@ export {
21
21
  discoverWorkflows,
22
22
  loadWorkflowFile,
23
23
  resolveWorkflowRef,
24
+ resolveWorkflowSource,
24
25
  workflowFileStem,
25
26
  workflowSearchDirs,
26
27
  type DiscoveredWorkflow,
@@ -83,6 +84,7 @@ export type {
83
84
  WorkflowRunResult,
84
85
  WorkflowRunState,
85
86
  WorkflowRunStatus,
87
+ WorkflowSource,
86
88
  WorkflowSessionBinding,
87
89
  WorkflowSessionEntryRecord,
88
90
  WorkflowStepRecord,