@osolmaz/pi-workflows 0.2.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 +72 -5
- 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.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +250 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +235 -0
- package/dist/controllers/sqlite.js +1087 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +95 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +12 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1118 -106
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -0
- package/dist/host/runner.js +379 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.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 +37 -2
- package/dist/workflows/engine.js +297 -29
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.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 +19 -15
- package/dist/workflows/loader.js +63 -19
- 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 +3 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +448 -53
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +27 -3
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +14 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- 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 +85 -28
- package/docs/workflows.md +161 -17
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/catalog.ts +22 -0
- package/src/builtins/monitor.workflow.ts +302 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1545 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +148 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +13 -2
- package/src/extension/index.ts +1325 -130
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +445 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/engine.ts +404 -41
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/index.ts +2 -0
- package/src/workflows/loader.ts +76 -22
- package/src/workflows/migrate-sources.ts +167 -0
- package/src/workflows/schema.ts +3 -2
- package/src/workflows/store.ts +557 -48
- package/src/workflows/types.ts +22 -3
package/src/workflows/loader.ts
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
import { createJiti } from "jiti";
|
|
7
|
+
import type { BuiltinWorkflowCatalog } from "./catalog.js";
|
|
6
8
|
import { isWorkflowDefinition } from "./definition.js";
|
|
7
|
-
import
|
|
9
|
+
import { WorkflowSourceChangedError } from "./errors.js";
|
|
10
|
+
import type { WorkflowDefinition, WorkflowSource } from "./types.js";
|
|
8
11
|
|
|
9
12
|
const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts", ".workflow.mjs"];
|
|
10
13
|
|
|
11
14
|
export type DiscoveredWorkflow = {
|
|
12
15
|
name: string;
|
|
13
|
-
|
|
14
|
-
source: "project" | "global" | "path";
|
|
16
|
+
ref: string;
|
|
17
|
+
source: "project" | "global" | "builtin" | "path";
|
|
15
18
|
};
|
|
16
19
|
|
|
17
20
|
export type WorkflowSearchPaths = {
|
|
@@ -19,7 +22,13 @@ export type WorkflowSearchPaths = {
|
|
|
19
22
|
homeDir?: string;
|
|
20
23
|
};
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
export type ResolvedWorkflow = {
|
|
26
|
+
definition: WorkflowDefinition;
|
|
27
|
+
source: WorkflowSource;
|
|
28
|
+
sourceKind: DiscoveredWorkflow["source"];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Directories scanned for user workflow files, in precedence order. */
|
|
23
32
|
export function workflowSearchDirs(
|
|
24
33
|
options: WorkflowSearchPaths,
|
|
25
34
|
): { dir: string; source: "project" | "global" }[] {
|
|
@@ -30,6 +39,13 @@ export function workflowSearchDirs(
|
|
|
30
39
|
];
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** SHA-256 of a user workflow source file. */
|
|
43
|
+
export async function hashWorkflowSource(filePath: string): Promise<string> {
|
|
44
|
+
return createHash("sha256")
|
|
45
|
+
.update(await fs.readFile(path.resolve(filePath)))
|
|
46
|
+
.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
function isWorkflowFile(fileName: string): boolean {
|
|
34
50
|
return WORKFLOW_FILE_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
35
51
|
}
|
|
@@ -40,12 +56,11 @@ export function workflowFileStem(filePath: string): string {
|
|
|
40
56
|
return suffix ? base.slice(0, -suffix.length) : base;
|
|
41
57
|
}
|
|
42
58
|
|
|
43
|
-
// Alias
|
|
44
|
-
//
|
|
45
|
-
// (tests, tsx) or from the built dist inside the installed package.
|
|
59
|
+
// Alias package imports to this process's workflow API. User files can reload,
|
|
60
|
+
// but their node constructors and validators remain from one engine version.
|
|
46
61
|
const SELF_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
|
|
47
62
|
|
|
48
|
-
/** Load a workflow module from disk.
|
|
63
|
+
/** Load a user workflow module from disk. */
|
|
49
64
|
export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefinition> {
|
|
50
65
|
const absolutePath = path.resolve(filePath);
|
|
51
66
|
const jiti = createJiti(pathToFileURL(absolutePath).href, {
|
|
@@ -60,22 +75,26 @@ export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefini
|
|
|
60
75
|
return loaded;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
|
-
/** Discover
|
|
78
|
+
/** Discover user workflows first, then unshadowed catalog built-ins. */
|
|
64
79
|
export async function discoverWorkflows(
|
|
65
80
|
options: WorkflowSearchPaths,
|
|
81
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
66
82
|
): Promise<DiscoveredWorkflow[]> {
|
|
67
83
|
const discovered: DiscoveredWorkflow[] = [];
|
|
68
84
|
const seenNames = new Set<string>();
|
|
69
85
|
for (const { dir, source } of workflowSearchDirs(options)) {
|
|
70
86
|
for (const filePath of await listWorkflowFiles(dir)) {
|
|
71
87
|
const name = workflowFileStem(filePath);
|
|
72
|
-
if (seenNames.has(name))
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
88
|
+
if (seenNames.has(name)) continue;
|
|
75
89
|
seenNames.add(name);
|
|
76
|
-
discovered.push({ name,
|
|
90
|
+
discovered.push({ name, ref: filePath, source });
|
|
77
91
|
}
|
|
78
92
|
}
|
|
93
|
+
for (const builtin of catalog?.list() ?? []) {
|
|
94
|
+
if (seenNames.has(builtin.definition.name)) continue;
|
|
95
|
+
seenNames.add(builtin.definition.name);
|
|
96
|
+
discovered.push({ name: builtin.definition.name, ref: builtin.ref, source: "builtin" });
|
|
97
|
+
}
|
|
79
98
|
return discovered;
|
|
80
99
|
}
|
|
81
100
|
|
|
@@ -92,26 +111,61 @@ async function listWorkflowFiles(dir: string): Promise<string[]> {
|
|
|
92
111
|
.sort();
|
|
93
112
|
}
|
|
94
113
|
|
|
95
|
-
/**
|
|
96
|
-
* Resolve a `/workflow` argument to a workflow file. Accepts a discovered
|
|
97
|
-
* workflow name or a direct path to a `*.workflow.ts` file.
|
|
98
|
-
*/
|
|
114
|
+
/** Resolve a workflow name, stable built-in ref, or direct user file path. */
|
|
99
115
|
export async function resolveWorkflowRef(
|
|
100
116
|
ref: string,
|
|
101
117
|
options: WorkflowSearchPaths,
|
|
102
|
-
|
|
118
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
119
|
+
): Promise<ResolvedWorkflow> {
|
|
120
|
+
if (ref.startsWith("builtin:")) {
|
|
121
|
+
const id = ref.slice("builtin:".length);
|
|
122
|
+
const builtin = catalog?.get(id);
|
|
123
|
+
if (builtin === undefined) throw new Error(`Unknown built-in workflow ${JSON.stringify(ref)}`);
|
|
124
|
+
return {
|
|
125
|
+
definition: builtin.definition,
|
|
126
|
+
source: { kind: "builtin", id: builtin.id, revision: builtin.revision },
|
|
127
|
+
sourceKind: "builtin",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
103
130
|
if (looksLikePath(ref)) {
|
|
104
131
|
const absolutePath = path.resolve(options.cwd, ref);
|
|
105
132
|
await fs.access(absolutePath);
|
|
106
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
definition: await loadWorkflowFile(absolutePath),
|
|
135
|
+
source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
|
|
136
|
+
sourceKind: "path",
|
|
137
|
+
};
|
|
107
138
|
}
|
|
108
|
-
const discovered = await discoverWorkflows(options);
|
|
139
|
+
const discovered = await discoverWorkflows(options, catalog);
|
|
109
140
|
const match = discovered.find((workflow) => workflow.name === ref);
|
|
110
|
-
if (
|
|
141
|
+
if (match === undefined) {
|
|
111
142
|
const available = discovered.map((workflow) => workflow.name).join(", ") || "(none)";
|
|
112
143
|
throw new Error(`Unknown workflow ${JSON.stringify(ref)}. Available workflows: ${available}`);
|
|
113
144
|
}
|
|
114
|
-
return
|
|
145
|
+
if (match.source === "builtin") return await resolveWorkflowRef(match.ref, options, catalog);
|
|
146
|
+
const absolutePath = path.resolve(match.ref);
|
|
147
|
+
return {
|
|
148
|
+
definition: await loadWorkflowFile(absolutePath),
|
|
149
|
+
source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
|
|
150
|
+
sourceKind: match.source,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve an already persisted canonical source. */
|
|
155
|
+
export async function resolveWorkflowSource(
|
|
156
|
+
source: WorkflowSource,
|
|
157
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
158
|
+
runId = source.kind === "builtin" ? `builtin:${source.id}` : source.path,
|
|
159
|
+
): Promise<WorkflowDefinition> {
|
|
160
|
+
if (source.kind === "builtin") {
|
|
161
|
+
if (catalog === undefined) throw new Error(`No built-in workflow catalog for ${source.id}`);
|
|
162
|
+
return catalog.resolve(source, runId);
|
|
163
|
+
}
|
|
164
|
+
const actualHash = await hashWorkflowSource(source.path);
|
|
165
|
+
if (actualHash !== source.hash) {
|
|
166
|
+
throw new WorkflowSourceChangedError(runId);
|
|
167
|
+
}
|
|
168
|
+
return await loadWorkflowFile(source.path);
|
|
115
169
|
}
|
|
116
170
|
|
|
117
171
|
function looksLikePath(ref: string): boolean {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { BuiltinWorkflowCatalog } from "./catalog.js";
|
|
5
|
+
import { WorkflowRunStore, listRunBundles } from "./store.js";
|
|
6
|
+
import type { WorkflowRunState, WorkflowSource } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export type LegacySourceMigrationQueue = {
|
|
9
|
+
repairCanonicalWorkflowSourceRun(options: {
|
|
10
|
+
runId: string;
|
|
11
|
+
workflowName: string;
|
|
12
|
+
workflowSourceRef: string;
|
|
13
|
+
runnerId: string;
|
|
14
|
+
claimToken: string;
|
|
15
|
+
leaseMs: number;
|
|
16
|
+
}): "unchanged" | "claimed" | false;
|
|
17
|
+
claimLegacyWorkflowSourceRun(options: {
|
|
18
|
+
runId: string;
|
|
19
|
+
workflowName: string;
|
|
20
|
+
oldWorkflowPath: string;
|
|
21
|
+
workflowSourceRef: string;
|
|
22
|
+
runnerId: string;
|
|
23
|
+
claimToken: string;
|
|
24
|
+
leaseMs: number;
|
|
25
|
+
}): boolean;
|
|
26
|
+
parkWorkflowRun(options: { runId: string; claimToken: string }): boolean;
|
|
27
|
+
getWorkflowRun(runId: string): { status: "claimed" | "parked" | "done" } | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const MIGRATION_LEASE_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export type LegacySourceMigrationResult = {
|
|
33
|
+
migratedRunIds: string[];
|
|
34
|
+
blocked: { runId: string; reason: string }[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One bounded migration for nonterminal runs created before canonical sources. */
|
|
38
|
+
export async function migrateLegacyWorkflowSources(options: {
|
|
39
|
+
catalog: BuiltinWorkflowCatalog;
|
|
40
|
+
store?: WorkflowRunStore;
|
|
41
|
+
queue?: LegacySourceMigrationQueue;
|
|
42
|
+
}): Promise<LegacySourceMigrationResult> {
|
|
43
|
+
const store = options.store ?? new WorkflowRunStore();
|
|
44
|
+
const result: LegacySourceMigrationResult = { migratedRunIds: [], blocked: [] };
|
|
45
|
+
for (const bundle of await listRunBundles(store.outputRoot)) {
|
|
46
|
+
const state = bundle.state;
|
|
47
|
+
if (state.status !== "running" && state.status !== "waiting") continue;
|
|
48
|
+
if (state.workflowSource !== undefined) {
|
|
49
|
+
if (options.queue === undefined) continue;
|
|
50
|
+
const claimToken = randomUUID();
|
|
51
|
+
const repaired = options.queue.repairCanonicalWorkflowSourceRun({
|
|
52
|
+
runId: state.runId,
|
|
53
|
+
workflowName: state.workflowName,
|
|
54
|
+
workflowSourceRef: sourceRef(state.workflowSource),
|
|
55
|
+
runnerId: `source-migration-${process.pid}`,
|
|
56
|
+
claimToken,
|
|
57
|
+
leaseMs: MIGRATION_LEASE_MS,
|
|
58
|
+
});
|
|
59
|
+
if (repaired === false) {
|
|
60
|
+
// Another runner can own a canonical run during startup. Its source is
|
|
61
|
+
// already safe; the normal queue lease will make it claimable later.
|
|
62
|
+
} else if (
|
|
63
|
+
repaired === "claimed" &&
|
|
64
|
+
!options.queue.parkWorkflowRun({ runId: state.runId, claimToken })
|
|
65
|
+
) {
|
|
66
|
+
result.blocked.push({
|
|
67
|
+
runId: state.runId,
|
|
68
|
+
reason: "canonical queue repair could not release its claim",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (state.workflowPath === undefined || state.workflowHash === undefined) {
|
|
74
|
+
result.blocked.push({ runId: state.runId, reason: "legacy workflow identity is incomplete" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const legacyPath = {
|
|
78
|
+
workflowName: state.workflowName,
|
|
79
|
+
workflowPath: state.workflowPath,
|
|
80
|
+
};
|
|
81
|
+
const pathEntry = options.catalog.legacyPathEntry(legacyPath);
|
|
82
|
+
let workflowSource: WorkflowSource;
|
|
83
|
+
let workflowSourceRef: string;
|
|
84
|
+
if (pathEntry === undefined) {
|
|
85
|
+
workflowSource = {
|
|
86
|
+
kind: "file",
|
|
87
|
+
path: path.resolve(state.workflowPath),
|
|
88
|
+
hash: state.workflowHash,
|
|
89
|
+
};
|
|
90
|
+
workflowSourceRef = workflowSource.path;
|
|
91
|
+
} else {
|
|
92
|
+
const legacy = options.catalog.matchLegacy({
|
|
93
|
+
...legacyPath,
|
|
94
|
+
workflowHash: state.workflowHash,
|
|
95
|
+
});
|
|
96
|
+
if (legacy === undefined) {
|
|
97
|
+
const reason = `legacy built-in ${pathEntry.id} has an unknown source revision`;
|
|
98
|
+
result.blocked.push({ runId: state.runId, reason });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
workflowSource = {
|
|
102
|
+
kind: "builtin",
|
|
103
|
+
id: legacy.entry.id,
|
|
104
|
+
revision: legacy.revision,
|
|
105
|
+
};
|
|
106
|
+
workflowSourceRef = legacy.entry.ref;
|
|
107
|
+
}
|
|
108
|
+
const claimToken = randomUUID();
|
|
109
|
+
const queueIsDone = options.queue?.getWorkflowRun(state.runId)?.status === "done";
|
|
110
|
+
if (
|
|
111
|
+
options.queue !== undefined &&
|
|
112
|
+
!queueIsDone &&
|
|
113
|
+
!options.queue.claimLegacyWorkflowSourceRun({
|
|
114
|
+
runId: state.runId,
|
|
115
|
+
workflowName: state.workflowName,
|
|
116
|
+
oldWorkflowPath: state.workflowPath,
|
|
117
|
+
workflowSourceRef,
|
|
118
|
+
runnerId: `source-migration-${process.pid}`,
|
|
119
|
+
claimToken,
|
|
120
|
+
leaseMs: MIGRATION_LEASE_MS,
|
|
121
|
+
})
|
|
122
|
+
) {
|
|
123
|
+
result.blocked.push({
|
|
124
|
+
runId: state.runId,
|
|
125
|
+
reason: "matching queue row is active or unavailable",
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const migrated: WorkflowRunState = {
|
|
130
|
+
...state,
|
|
131
|
+
workflowSource,
|
|
132
|
+
};
|
|
133
|
+
delete migrated.workflowPath;
|
|
134
|
+
delete migrated.workflowHash;
|
|
135
|
+
const migratedManifest = {
|
|
136
|
+
...bundle.manifest,
|
|
137
|
+
workflowSource: migrated.workflowSource,
|
|
138
|
+
};
|
|
139
|
+
delete (migratedManifest as typeof migratedManifest & { workflowPath?: string }).workflowPath;
|
|
140
|
+
// State is the migration commit point. Queue and manifest updates are
|
|
141
|
+
// idempotent, so a crash before this write can safely retry.
|
|
142
|
+
await writeJsonAtomic(path.join(bundle.runDir, "manifest.json"), migratedManifest);
|
|
143
|
+
await writeJsonAtomic(path.join(bundle.runDir, "state.json"), migrated);
|
|
144
|
+
if (
|
|
145
|
+
options.queue !== undefined &&
|
|
146
|
+
!queueIsDone &&
|
|
147
|
+
!options.queue.parkWorkflowRun({ runId: state.runId, claimToken })
|
|
148
|
+
) {
|
|
149
|
+
result.blocked.push({
|
|
150
|
+
runId: state.runId,
|
|
151
|
+
reason: "migration completed but its queue claim could not be released",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
result.migratedRunIds.push(state.runId);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sourceRef(source: WorkflowSource): string {
|
|
160
|
+
return source.kind === "builtin" ? `builtin:${source.id}` : source.path;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
164
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.source-migration.tmp`;
|
|
165
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
166
|
+
await fs.rename(tempPath, filePath);
|
|
167
|
+
}
|
package/src/workflows/schema.ts
CHANGED
|
@@ -34,9 +34,10 @@ function assertOptionalFunction(value: unknown, description: string): void {
|
|
|
34
34
|
function assertCommonNodeFields(node: WorkflowNodeDefinition, nodeId: string): void {
|
|
35
35
|
if (
|
|
36
36
|
node.timeoutMs !== undefined &&
|
|
37
|
+
typeof node.timeoutMs !== "function" &&
|
|
37
38
|
(typeof node.timeoutMs !== "number" || !Number.isFinite(node.timeoutMs) || node.timeoutMs <= 0)
|
|
38
39
|
) {
|
|
39
|
-
fail(`node ${nodeId} timeoutMs must be a finite positive number`);
|
|
40
|
+
fail(`node ${nodeId} timeoutMs must be a finite positive number or function`);
|
|
40
41
|
}
|
|
41
42
|
if (node.statusDetail !== undefined && typeof node.statusDetail !== "string") {
|
|
42
43
|
fail(`node ${nodeId} statusDetail must be a string`);
|
|
@@ -160,7 +161,7 @@ function assertValidEdgeShape(edge: WorkflowEdge, index: number): void {
|
|
|
160
161
|
* Names claimed by `/workflow` subcommands; a workflow with one of these
|
|
161
162
|
* names could never be started because the keyword wins the argument slot.
|
|
162
163
|
*/
|
|
163
|
-
const RESERVED_WORKFLOW_NAMES = new Set(["cancel", "list", "pause", "resume"]);
|
|
164
|
+
const RESERVED_WORKFLOW_NAMES = new Set(["answer", "cancel", "list", "pause", "resume", "status"]);
|
|
164
165
|
|
|
165
166
|
export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinition): void {
|
|
166
167
|
assertRecord(definition, "workflow");
|