@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.
- package/README.md +3 -2
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +22 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.js +17 -1
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +27 -4
- package/dist/controllers/sqlite.js +83 -5
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
- package/dist/controllers/workflow-engine-scheduler.js +3 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +84 -29
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +48 -19
- package/dist/host/runner.js.map +1 -1
- package/dist/workflows/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/engine.d.ts +5 -6
- package/dist/workflows/engine.js +71 -33
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +2 -2
- package/dist/workflows/index.js +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +18 -16
- package/dist/workflows/loader.js +58 -23
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +41 -0
- package/dist/workflows/migrate-sources.js +129 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.js +2 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +2 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +18 -4
- package/docs/development.md +5 -3
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/run-bundles.md +22 -4
- package/docs/workflows.md +34 -10
- package/package.json +1 -1
- package/src/builtins/catalog.ts +22 -0
- package/src/builtins/monitor.workflow.ts +25 -1
- package/src/controllers/sqlite.ts +128 -10
- package/src/controllers/workflow-engine-scheduler.ts +5 -2
- package/src/extension/executor.ts +12 -1
- package/src/extension/index.ts +106 -37
- package/src/host/runner.ts +58 -19
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/engine.ts +102 -53
- package/src/workflows/index.ts +2 -0
- package/src/workflows/loader.ts +70 -26
- package/src/workflows/migrate-sources.ts +167 -0
- package/src/workflows/schema.ts +2 -1
- package/src/workflows/store.ts +2 -2
- package/src/workflows/types.ts +13 -4
package/src/host/runner.ts
CHANGED
|
@@ -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 {
|
|
21
|
-
import {
|
|
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(
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
214
|
+
this.log(`resuming ${record.workflowName} run ${runId}`);
|
|
199
215
|
let workflow: WorkflowDefinition;
|
|
200
|
-
let
|
|
216
|
+
let workflowSource: import("../workflows/types.js").WorkflowSource;
|
|
201
217
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
287
|
+
this.recordEvent(runId, record.workflowName, "resumed", { runnerId: this.runnerId });
|
|
249
288
|
try {
|
|
250
|
-
const result = await engine.resumeRun(workflow, runId, {
|
|
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.
|
|
256
|
-
this.log(`parked ${record.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
369
|
+
this.recordEvent(record.runId, record.workflowName, actualStatus, {});
|
|
331
370
|
} else {
|
|
332
|
-
this.recordEvent(record.runId, record.
|
|
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
|
+
}
|
package/src/workflows/engine.ts
CHANGED
|
@@ -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: {
|
|
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.
|
|
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: {
|
|
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
|
|
211
|
-
|
|
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
|
-
...(
|
|
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: {
|
|
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
|
|
302
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 {
|
package/src/workflows/index.ts
CHANGED
|
@@ -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,
|