@osolmaz/pi-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.
- package/README.md +50 -16
- package/dist/builtins/autodevise.workflow.d.ts +58 -0
- package/dist/builtins/autodevise.workflow.js +190 -0
- package/dist/builtins/autodevise.workflow.js.map +1 -0
- package/dist/builtins/autoimplement.workflow.d.ts +154 -0
- package/dist/builtins/autoimplement.workflow.js +729 -0
- package/dist/builtins/autoimplement.workflow.js.map +1 -0
- package/dist/builtins/catalog.js +5 -1
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +3 -0
- package/dist/builtins/index.js +4 -0
- package/dist/builtins/index.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +25 -3
- package/dist/builtins/monitor.workflow.js +200 -13
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/extension/herdr-viewer.js +2 -6
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/render/graph-render.js +13 -2
- package/dist/render/graph-render.js.map +1 -1
- package/dist/workflows/catalog.d.ts +1 -0
- package/dist/workflows/catalog.js +6 -0
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.d.ts +45 -0
- package/dist/workflows/composition.js +471 -0
- package/dist/workflows/composition.js.map +1 -0
- package/dist/workflows/decision.d.ts +11 -5
- package/dist/workflows/decision.js.map +1 -1
- package/dist/workflows/definition.d.ts +22 -3
- package/dist/workflows/definition.js +46 -3
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +115 -16
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/graph.js +8 -6
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -2
- package/dist/workflows/index.js +2 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -4
- package/dist/workflows/loader.js +118 -18
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.d.ts +3 -1
- package/dist/workflows/schema.js +49 -2
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +32 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +77 -2
- package/docs/CONTROLLERS.md +1 -1
- package/docs/DESIGN_PHILOSOPHY.md +1 -1
- package/docs/MONITOR.md +35 -18
- package/docs/WORKFLOW_COMPOSITION.md +326 -0
- package/docs/plans/2026-08-19-workflow-composition-plan.md +300 -0
- package/docs/run-bundles.md +24 -10
- package/docs/workflows.md +65 -12
- package/examples/workflows/autodevise.workflow.ts +1 -0
- package/examples/workflows/autoimplement.workflow.ts +1 -92
- package/herdr-plugin.toml +1 -1
- package/package.json +5 -1
- package/skills/monitor/SKILL.md +6 -1
- package/skills/pi-workflows/SKILL.md +3 -1
- package/src/builtins/autodevise.workflow.ts +231 -0
- package/src/builtins/autoimplement.workflow.ts +856 -0
- package/src/builtins/catalog.ts +5 -1
- package/src/builtins/index.ts +13 -0
- package/src/builtins/monitor.workflow.ts +242 -15
- package/src/extension/herdr-viewer.ts +1 -6
- package/src/render/graph-render.ts +14 -2
- package/src/workflows/catalog.ts +7 -0
- package/src/workflows/composition.ts +627 -0
- package/src/workflows/decision.ts +12 -5
- package/src/workflows/definition.ts +118 -8
- package/src/workflows/engine.ts +151 -18
- package/src/workflows/graph.ts +8 -6
- package/src/workflows/index.ts +20 -0
- package/src/workflows/loader.ts +186 -18
- package/src/workflows/schema.ts +62 -2
- package/src/workflows/store.ts +37 -2
- package/src/workflows/types.ts +109 -2
- package/examples/workflows/elegant-solution.workflow.ts +0 -95
package/src/workflows/loader.ts
CHANGED
|
@@ -5,9 +5,19 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createJiti } from "jiti";
|
|
7
7
|
import type { BuiltinWorkflowCatalog } from "./catalog.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
compileWorkflowDefinition,
|
|
10
|
+
compositionMetadata,
|
|
11
|
+
type WorkflowCompositionSourceMap,
|
|
12
|
+
} from "./composition.js";
|
|
13
|
+
import { defineWorkflow, isWorkflowDefinition } from "./definition.js";
|
|
9
14
|
import { WorkflowSourceChangedError } from "./errors.js";
|
|
10
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
WorkflowDefinition,
|
|
17
|
+
WorkflowIncludeDefinition,
|
|
18
|
+
WorkflowMountedSource,
|
|
19
|
+
WorkflowSource,
|
|
20
|
+
} from "./types.js";
|
|
11
21
|
|
|
12
22
|
const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts", ".workflow.mjs"];
|
|
13
23
|
|
|
@@ -25,9 +35,12 @@ export type WorkflowSearchPaths = {
|
|
|
25
35
|
export type ResolvedWorkflow = {
|
|
26
36
|
definition: WorkflowDefinition;
|
|
27
37
|
source: WorkflowSource;
|
|
38
|
+
sources: WorkflowMountedSource[];
|
|
28
39
|
sourceKind: DiscoveredWorkflow["source"];
|
|
29
40
|
};
|
|
30
41
|
|
|
42
|
+
type SingleResolvedWorkflow = Omit<ResolvedWorkflow, "sources">;
|
|
43
|
+
|
|
31
44
|
/** Directories scanned for user workflow files, in precedence order. */
|
|
32
45
|
export function workflowSearchDirs(
|
|
33
46
|
options: WorkflowSearchPaths,
|
|
@@ -59,6 +72,12 @@ export function workflowFileStem(filePath: string): string {
|
|
|
59
72
|
// Alias package imports to this process's workflow API. User files can reload,
|
|
60
73
|
// but their node constructors and validators remain from one engine version.
|
|
61
74
|
const SELF_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
|
|
75
|
+
const BUILTINS_ENTRY = path.join(
|
|
76
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
77
|
+
"..",
|
|
78
|
+
"builtins",
|
|
79
|
+
"index",
|
|
80
|
+
);
|
|
62
81
|
|
|
63
82
|
/** Load a user workflow module from disk. */
|
|
64
83
|
export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefinition> {
|
|
@@ -66,7 +85,11 @@ export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefini
|
|
|
66
85
|
const jiti = createJiti(pathToFileURL(absolutePath).href, {
|
|
67
86
|
interopDefault: true,
|
|
68
87
|
moduleCache: false,
|
|
69
|
-
alias: {
|
|
88
|
+
alias: {
|
|
89
|
+
"@osolmaz/pi-workflows/builtins": BUILTINS_ENTRY,
|
|
90
|
+
"@osolmaz/pi-workflows": SELF_ENTRY,
|
|
91
|
+
"pi-workflows": SELF_ENTRY,
|
|
92
|
+
},
|
|
70
93
|
});
|
|
71
94
|
const loaded = (await jiti.import(absolutePath, { default: true })) as unknown;
|
|
72
95
|
if (!isWorkflowDefinition(loaded)) {
|
|
@@ -111,12 +134,42 @@ async function listWorkflowFiles(dir: string): Promise<string[]> {
|
|
|
111
134
|
.sort();
|
|
112
135
|
}
|
|
113
136
|
|
|
114
|
-
/** Resolve a workflow
|
|
137
|
+
/** Resolve a workflow and every nested include before returning it. */
|
|
115
138
|
export async function resolveWorkflowRef(
|
|
116
139
|
ref: string,
|
|
117
140
|
options: WorkflowSearchPaths,
|
|
118
141
|
catalog?: BuiltinWorkflowCatalog,
|
|
119
142
|
): Promise<ResolvedWorkflow> {
|
|
143
|
+
const root = await resolveSingleWorkflowRef(ref, options, catalog, options.cwd);
|
|
144
|
+
const sourceMap: WorkflowCompositionSourceMap = new Map([[root.definition, root.source]]);
|
|
145
|
+
const activeSources: string[] = [];
|
|
146
|
+
const definition = await resolveIncludes(
|
|
147
|
+
root.definition,
|
|
148
|
+
sourceBaseDir(root.source),
|
|
149
|
+
options,
|
|
150
|
+
catalog,
|
|
151
|
+
sourceMap,
|
|
152
|
+
activeSources,
|
|
153
|
+
sourceKey(root.source),
|
|
154
|
+
);
|
|
155
|
+
const compiled = compileWorkflowDefinition(definition, {
|
|
156
|
+
rootSource: root.source,
|
|
157
|
+
sourceMap,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
definition: compiled,
|
|
161
|
+
source: root.source,
|
|
162
|
+
sources: compositionMetadata(compiled)?.sources ?? [],
|
|
163
|
+
sourceKind: root.sourceKind,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveSingleWorkflowRef(
|
|
168
|
+
ref: string,
|
|
169
|
+
options: WorkflowSearchPaths,
|
|
170
|
+
catalog: BuiltinWorkflowCatalog | undefined,
|
|
171
|
+
relativeBase: string,
|
|
172
|
+
): Promise<SingleResolvedWorkflow> {
|
|
120
173
|
if (ref.startsWith("builtin:")) {
|
|
121
174
|
const id = ref.slice("builtin:".length);
|
|
122
175
|
const builtin = catalog?.get(id);
|
|
@@ -128,7 +181,7 @@ export async function resolveWorkflowRef(
|
|
|
128
181
|
};
|
|
129
182
|
}
|
|
130
183
|
if (looksLikePath(ref)) {
|
|
131
|
-
const absolutePath = path.resolve(
|
|
184
|
+
const absolutePath = path.resolve(relativeBase, ref);
|
|
132
185
|
await fs.access(absolutePath);
|
|
133
186
|
return {
|
|
134
187
|
definition: await loadWorkflowFile(absolutePath),
|
|
@@ -142,30 +195,145 @@ export async function resolveWorkflowRef(
|
|
|
142
195
|
const available = discovered.map((workflow) => workflow.name).join(", ") || "(none)";
|
|
143
196
|
throw new Error(`Unknown workflow ${JSON.stringify(ref)}. Available workflows: ${available}`);
|
|
144
197
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
198
|
+
const resolved = await resolveSingleWorkflowRef(match.ref, options, catalog, options.cwd);
|
|
199
|
+
return { ...resolved, sourceKind: match.source };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function resolveIncludes(
|
|
203
|
+
workflow: WorkflowDefinition,
|
|
204
|
+
baseDir: string | undefined,
|
|
205
|
+
options: WorkflowSearchPaths,
|
|
206
|
+
catalog: BuiltinWorkflowCatalog | undefined,
|
|
207
|
+
sourceMap: WorkflowCompositionSourceMap,
|
|
208
|
+
activeSources: string[],
|
|
209
|
+
currentSourceKey: string,
|
|
210
|
+
): Promise<WorkflowDefinition> {
|
|
211
|
+
const cycleAt = activeSources.indexOf(currentSourceKey);
|
|
212
|
+
if (cycleAt >= 0) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Workflow include source cycle: ${[...activeSources.slice(cycleAt), currentSourceKey].join(" -> ")}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
activeSources.push(currentSourceKey);
|
|
218
|
+
const resolvedIncludes: Record<string, WorkflowIncludeDefinition> = {};
|
|
219
|
+
for (const [mountName, include] of Object.entries(workflow.includes ?? {})) {
|
|
220
|
+
let child: WorkflowDefinition;
|
|
221
|
+
let childSource: WorkflowSource | undefined;
|
|
222
|
+
let childBaseDir = baseDir;
|
|
223
|
+
if (typeof include.workflow === "string") {
|
|
224
|
+
if (
|
|
225
|
+
baseDir === undefined &&
|
|
226
|
+
looksLikePath(include.workflow) &&
|
|
227
|
+
!path.isAbsolute(include.workflow)
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Built-in workflow ${workflow.name} cannot resolve relative include ${include.workflow}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const resolved = await resolveSingleWorkflowRef(
|
|
234
|
+
include.workflow,
|
|
235
|
+
options,
|
|
236
|
+
catalog,
|
|
237
|
+
baseDir ?? options.cwd,
|
|
238
|
+
);
|
|
239
|
+
child = resolved.definition;
|
|
240
|
+
childSource = resolved.source;
|
|
241
|
+
childBaseDir = sourceBaseDir(resolved.source);
|
|
242
|
+
} else {
|
|
243
|
+
child = include.workflow;
|
|
244
|
+
childSource = await sourceForDirectDefinition(child, catalog);
|
|
245
|
+
childBaseDir = childSource ? sourceBaseDir(childSource) : baseDir;
|
|
246
|
+
}
|
|
247
|
+
if (childSource !== undefined) sourceMap.set(child, childSource);
|
|
248
|
+
assertContractCompatible(include, child, mountName);
|
|
249
|
+
const childKey = childSource ? sourceKey(childSource) : `memory:${child.name}`;
|
|
250
|
+
const resolvedChild = await resolveIncludes(
|
|
251
|
+
child,
|
|
252
|
+
childBaseDir,
|
|
253
|
+
options,
|
|
254
|
+
catalog,
|
|
255
|
+
sourceMap,
|
|
256
|
+
activeSources,
|
|
257
|
+
childKey,
|
|
258
|
+
);
|
|
259
|
+
if (childSource !== undefined) sourceMap.set(resolvedChild, childSource);
|
|
260
|
+
resolvedIncludes[mountName] = { ...include, workflow: resolvedChild };
|
|
261
|
+
}
|
|
262
|
+
activeSources.pop();
|
|
263
|
+
if (Object.keys(resolvedIncludes).length === 0) return workflow;
|
|
264
|
+
const resolved = defineWorkflow({ ...workflow, includes: resolvedIncludes });
|
|
265
|
+
const ownSource = sourceMap.get(workflow);
|
|
266
|
+
if (ownSource !== undefined) sourceMap.set(resolved, ownSource);
|
|
267
|
+
return resolved;
|
|
152
268
|
}
|
|
153
269
|
|
|
154
|
-
|
|
270
|
+
async function sourceForDirectDefinition(
|
|
271
|
+
workflow: WorkflowDefinition,
|
|
272
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
273
|
+
): Promise<WorkflowSource | undefined> {
|
|
274
|
+
const builtin = catalog?.sourceForDefinition(workflow);
|
|
275
|
+
if (builtin !== undefined) return builtin;
|
|
276
|
+
if (workflow.source === undefined) return undefined;
|
|
277
|
+
let filePath: string;
|
|
278
|
+
try {
|
|
279
|
+
filePath = fileURLToPath(workflow.source);
|
|
280
|
+
} catch {
|
|
281
|
+
throw new Error(`Workflow ${workflow.name} source must be a file URL: ${workflow.source}`);
|
|
282
|
+
}
|
|
283
|
+
return { kind: "file", path: filePath, hash: await hashWorkflowSource(filePath) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function assertContractCompatible(
|
|
287
|
+
include: WorkflowIncludeDefinition,
|
|
288
|
+
child: WorkflowDefinition,
|
|
289
|
+
mountName: string,
|
|
290
|
+
): void {
|
|
291
|
+
if (include.contract === undefined) return;
|
|
292
|
+
if (
|
|
293
|
+
include.contract.contractId !== undefined &&
|
|
294
|
+
child.contractId !== include.contract.contractId
|
|
295
|
+
) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Workflow include ${mountName} contract mismatch: expected ${include.contract.contractId}; got ${child.contractId ?? "none"}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const expected = Object.keys(include.contract.exits ?? {}).sort();
|
|
301
|
+
const actual = Object.keys(child.exits ?? {}).sort();
|
|
302
|
+
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Workflow include ${mountName} exit contract mismatch: expected ${expected.join(", ") || "none"}; got ${actual.join(", ") || "none"}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if ((include.contract.input === undefined) !== (child.input === undefined)) {
|
|
308
|
+
throw new Error(`Workflow include ${mountName} input contract mismatch`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sourceBaseDir(source: WorkflowSource): string | undefined {
|
|
313
|
+
return source.kind === "file" ? path.dirname(source.path) : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sourceKey(source: WorkflowSource): string {
|
|
317
|
+
return source.kind === "file"
|
|
318
|
+
? `file:${source.path}:${source.hash}`
|
|
319
|
+
: `builtin:${source.id}:${source.revision}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Resolve an already persisted canonical source and its includes. */
|
|
155
323
|
export async function resolveWorkflowSource(
|
|
156
324
|
source: WorkflowSource,
|
|
157
325
|
catalog?: BuiltinWorkflowCatalog,
|
|
158
326
|
runId = source.kind === "builtin" ? `builtin:${source.id}` : source.path,
|
|
327
|
+
options: WorkflowSearchPaths = { cwd: process.cwd() },
|
|
159
328
|
): Promise<WorkflowDefinition> {
|
|
160
329
|
if (source.kind === "builtin") {
|
|
161
330
|
if (catalog === undefined) throw new Error(`No built-in workflow catalog for ${source.id}`);
|
|
162
|
-
|
|
331
|
+
catalog.resolve(source, runId);
|
|
332
|
+
return (await resolveWorkflowRef(`builtin:${source.id}`, options, catalog)).definition;
|
|
163
333
|
}
|
|
164
334
|
const actualHash = await hashWorkflowSource(source.path);
|
|
165
|
-
if (actualHash !== source.hash)
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
return await loadWorkflowFile(source.path);
|
|
335
|
+
if (actualHash !== source.hash) throw new WorkflowSourceChangedError(runId);
|
|
336
|
+
return (await resolveWorkflowRef(source.path, options, catalog)).definition;
|
|
169
337
|
}
|
|
170
338
|
|
|
171
339
|
function looksLikePath(ref: string): boolean {
|
package/src/workflows/schema.ts
CHANGED
|
@@ -199,7 +199,10 @@ function assertValidEdgeShape(edge: WorkflowEdge, index: number): void {
|
|
|
199
199
|
*/
|
|
200
200
|
const RESERVED_WORKFLOW_NAMES = new Set(["answer", "cancel", "list", "pause", "resume", "status"]);
|
|
201
201
|
|
|
202
|
-
export function assertValidWorkflowDefinitionShape(
|
|
202
|
+
export function assertValidWorkflowDefinitionShape(
|
|
203
|
+
definition: WorkflowDefinition,
|
|
204
|
+
options: { compiled?: boolean } = {},
|
|
205
|
+
): void {
|
|
203
206
|
assertRecord(definition, "workflow");
|
|
204
207
|
if (typeof definition.name !== "string" || definition.name.length === 0) {
|
|
205
208
|
fail("workflow requires a name");
|
|
@@ -207,6 +210,17 @@ export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinitio
|
|
|
207
210
|
if (RESERVED_WORKFLOW_NAMES.has(definition.name)) {
|
|
208
211
|
fail(`workflow name ${JSON.stringify(definition.name)} is reserved for /workflow subcommands`);
|
|
209
212
|
}
|
|
213
|
+
if (definition.source !== undefined && typeof definition.source !== "string") {
|
|
214
|
+
fail("workflow source must be a string");
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
definition.contractId !== undefined &&
|
|
218
|
+
(typeof definition.contractId !== "string" ||
|
|
219
|
+
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(definition.contractId))
|
|
220
|
+
) {
|
|
221
|
+
fail("workflow contractId must be a stable identifier");
|
|
222
|
+
}
|
|
223
|
+
assertOptionalFunction(definition.input, "workflow input");
|
|
210
224
|
if (
|
|
211
225
|
definition.title !== undefined &&
|
|
212
226
|
typeof definition.title !== "string" &&
|
|
@@ -237,7 +251,14 @@ export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinitio
|
|
|
237
251
|
fail("workflow requires at least one node");
|
|
238
252
|
}
|
|
239
253
|
for (const [nodeId, node] of Object.entries(definition.nodes)) {
|
|
240
|
-
|
|
254
|
+
const segments = nodeId.split("/");
|
|
255
|
+
if (
|
|
256
|
+
segments.some(
|
|
257
|
+
(segment) =>
|
|
258
|
+
!NODE_ID_PATTERN.test(segment) || (!options.compiled && segment.startsWith("__piw_")),
|
|
259
|
+
) ||
|
|
260
|
+
(!options.compiled && segments.length !== 1)
|
|
261
|
+
) {
|
|
241
262
|
fail(`node id ${JSON.stringify(nodeId)} must match ${NODE_ID_PATTERN.source}`);
|
|
242
263
|
}
|
|
243
264
|
// Ids like __proto__ or toString would collide with Object prototype
|
|
@@ -248,6 +269,45 @@ export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinitio
|
|
|
248
269
|
assertRecord(node, `node ${nodeId}`);
|
|
249
270
|
assertValidNode(node, nodeId);
|
|
250
271
|
}
|
|
272
|
+
if (definition.includes !== undefined) {
|
|
273
|
+
assertRecord(definition.includes, "workflow includes");
|
|
274
|
+
for (const [mountName, include] of Object.entries(definition.includes)) {
|
|
275
|
+
if (!NODE_ID_PATTERN.test(mountName) || mountName.startsWith("__piw_")) {
|
|
276
|
+
fail(`include name ${JSON.stringify(mountName)} must match ${NODE_ID_PATTERN.source}`);
|
|
277
|
+
}
|
|
278
|
+
if (Object.hasOwn(definition.nodes, mountName)) {
|
|
279
|
+
fail(`include name ${JSON.stringify(mountName)} collides with a node id`);
|
|
280
|
+
}
|
|
281
|
+
assertRecord(include, `include ${mountName}`);
|
|
282
|
+
if (
|
|
283
|
+
typeof include.workflow !== "string" &&
|
|
284
|
+
(include.workflow === null || typeof include.workflow !== "object")
|
|
285
|
+
) {
|
|
286
|
+
fail(`include ${mountName} requires a workflow definition or reference`);
|
|
287
|
+
}
|
|
288
|
+
assertOptionalFunction(include.input, `include ${mountName} input`);
|
|
289
|
+
if (
|
|
290
|
+
include.contract !== undefined &&
|
|
291
|
+
(include.contract === null || typeof include.contract !== "object")
|
|
292
|
+
) {
|
|
293
|
+
fail(`include ${mountName} contract must be a workflow definition`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (definition.exits !== undefined) {
|
|
298
|
+
assertRecord(definition.exits, "workflow exits");
|
|
299
|
+
if (Object.keys(definition.exits).length === 0) fail("workflow exits must not be empty");
|
|
300
|
+
for (const [exitName, exit] of Object.entries(definition.exits)) {
|
|
301
|
+
if (!NODE_ID_PATTERN.test(exitName) || exitName.startsWith("__piw_")) {
|
|
302
|
+
fail(`exit name ${JSON.stringify(exitName)} must match ${NODE_ID_PATTERN.source}`);
|
|
303
|
+
}
|
|
304
|
+
assertRecord(exit, `exit ${exitName}`);
|
|
305
|
+
if (typeof exit.from !== "string" || exit.from.length === 0) {
|
|
306
|
+
fail(`exit ${exitName} requires from`);
|
|
307
|
+
}
|
|
308
|
+
assertOptionalFunction(exit.validate, `exit ${exitName} validate`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
251
311
|
if (!Array.isArray(definition.edges)) {
|
|
252
312
|
fail("workflow edges must be an array");
|
|
253
313
|
}
|
package/src/workflows/store.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ArtifactWriter, encodeValue } from "./artifacts.js";
|
|
6
|
+
import { compositionMetadata } from "./composition.js";
|
|
6
7
|
import type {
|
|
7
8
|
WorkflowDefinition,
|
|
8
9
|
WorkflowDefinitionSnapshot,
|
|
@@ -1350,6 +1351,8 @@ function createManifest(
|
|
|
1350
1351
|
workflowName: state.workflowName,
|
|
1351
1352
|
...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
|
|
1352
1353
|
...(state.workflowSource !== undefined ? { workflowSource: state.workflowSource } : {}),
|
|
1354
|
+
...(state.workflowSources !== undefined ? { workflowSources: state.workflowSources } : {}),
|
|
1355
|
+
...(state.definitionDigest !== undefined ? { definitionDigest: state.definitionDigest } : {}),
|
|
1353
1356
|
startedAt: state.startedAt,
|
|
1354
1357
|
...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
|
|
1355
1358
|
status: state.status,
|
|
@@ -1368,20 +1371,52 @@ function createManifest(
|
|
|
1368
1371
|
}
|
|
1369
1372
|
|
|
1370
1373
|
export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
|
|
1374
|
+
const composition = compositionMetadata(workflow)?.snapshot;
|
|
1371
1375
|
return {
|
|
1372
1376
|
schema: DEFINITION_SNAPSHOT_SCHEMA,
|
|
1373
1377
|
name: workflow.name,
|
|
1378
|
+
...(workflow.contractId !== undefined ? { contractId: workflow.contractId } : {}),
|
|
1374
1379
|
startAt: workflow.startAt,
|
|
1375
1380
|
nodes: Object.fromEntries(
|
|
1376
|
-
Object.entries(workflow.nodes).map(([nodeId, node]) => [
|
|
1381
|
+
Object.entries(workflow.nodes).map(([nodeId, node]) => [
|
|
1382
|
+
nodeId,
|
|
1383
|
+
snapshotNode(workflow, nodeId, node),
|
|
1384
|
+
]),
|
|
1377
1385
|
),
|
|
1378
1386
|
edges: structuredClone(workflow.edges),
|
|
1387
|
+
...(composition !== undefined ? { composition: structuredClone(composition) } : {}),
|
|
1379
1388
|
};
|
|
1380
1389
|
}
|
|
1381
1390
|
|
|
1382
|
-
function snapshotNode(
|
|
1391
|
+
function snapshotNode(
|
|
1392
|
+
workflow: WorkflowDefinition,
|
|
1393
|
+
nodeId: string,
|
|
1394
|
+
node: WorkflowNodeDefinition,
|
|
1395
|
+
): WorkflowNodeSnapshot {
|
|
1396
|
+
const composition = compositionMetadata(workflow);
|
|
1397
|
+
const entry = composition?.entries[nodeId];
|
|
1398
|
+
const exit = composition?.exits[nodeId];
|
|
1399
|
+
const scope = Object.values(composition?.scopes ?? {})
|
|
1400
|
+
.filter((candidate) => candidate.path !== "" && nodeId.startsWith(`${candidate.path}/`))
|
|
1401
|
+
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
1402
|
+
const mountPath = entry?.mountPath ?? exit?.mountPath ?? scope?.path;
|
|
1403
|
+
const localNodeId =
|
|
1404
|
+
entry !== undefined
|
|
1405
|
+
? entry.mountName
|
|
1406
|
+
: exit !== undefined
|
|
1407
|
+
? exit.exitName
|
|
1408
|
+
: scope !== undefined
|
|
1409
|
+
? nodeId.slice(scope.path.length + 1)
|
|
1410
|
+
: undefined;
|
|
1383
1411
|
const common: WorkflowNodeSnapshot = {
|
|
1384
1412
|
nodeType: node.nodeType,
|
|
1413
|
+
...(mountPath !== undefined ? { mountPath: mountPath.split("/") } : {}),
|
|
1414
|
+
...(localNodeId !== undefined ? { localNodeId } : {}),
|
|
1415
|
+
...(entry !== undefined
|
|
1416
|
+
? { includeTransition: "entry" as const }
|
|
1417
|
+
: exit !== undefined
|
|
1418
|
+
? { includeTransition: "exit" as const }
|
|
1419
|
+
: {}),
|
|
1385
1420
|
...(typeof node.timeoutMs === "number" ? { timeoutMs: node.timeoutMs } : {}),
|
|
1386
1421
|
...(node.statusDetail !== undefined ? { statusDetail: node.statusDetail } : {}),
|
|
1387
1422
|
};
|
package/src/workflows/types.ts
CHANGED
|
@@ -204,12 +204,88 @@ export type WorkflowPresentationContext = {
|
|
|
204
204
|
signal: AbortSignal;
|
|
205
205
|
};
|
|
206
206
|
|
|
207
|
-
|
|
207
|
+
/** Runtime parser that also carries its normalized TypeScript result type. */
|
|
208
|
+
export type WorkflowValueParser<T> = (value: unknown) => MaybePromise<T>;
|
|
209
|
+
|
|
210
|
+
export type WorkflowExitDefinition<TOutput = unknown> = {
|
|
211
|
+
/** Successful terminal node whose output leaves through this exit. */
|
|
212
|
+
from: string;
|
|
213
|
+
/** Optional runtime output normalizer and validator. */
|
|
214
|
+
validate?: WorkflowValueParser<TOutput>;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export type WorkflowExitMap = Record<string, WorkflowExitDefinition>;
|
|
218
|
+
|
|
219
|
+
export type WorkflowInputOf<TWorkflow> =
|
|
220
|
+
TWorkflow extends WorkflowDefinition<infer TInput, any, any> ? TInput : unknown;
|
|
221
|
+
|
|
222
|
+
export type WorkflowExitOutputs<TWorkflow> =
|
|
223
|
+
TWorkflow extends WorkflowDefinition<any, infer TExits, any>
|
|
224
|
+
? {
|
|
225
|
+
[K in keyof TExits]: TExits[K] extends WorkflowExitDefinition<infer TOutput>
|
|
226
|
+
? TOutput
|
|
227
|
+
: unknown;
|
|
228
|
+
}
|
|
229
|
+
: Record<string, unknown>;
|
|
230
|
+
|
|
231
|
+
export type WorkflowIncludedResult<TWorkflow> = {
|
|
232
|
+
[K in keyof WorkflowExitOutputs<TWorkflow>]: {
|
|
233
|
+
exit: K;
|
|
234
|
+
output: WorkflowExitOutputs<TWorkflow>[K];
|
|
235
|
+
};
|
|
236
|
+
}[keyof WorkflowExitOutputs<TWorkflow>];
|
|
237
|
+
|
|
238
|
+
export type WorkflowIncludeDefinition<
|
|
239
|
+
TWorkflow extends WorkflowDefinition<any, any, any> = WorkflowDefinition<any, any, any>,
|
|
240
|
+
> = {
|
|
241
|
+
/** Imported child definition or dynamic discovered name/path. */
|
|
242
|
+
workflow: TWorkflow | string;
|
|
243
|
+
/** Pure parent-to-child input mapping, evaluated on every mount entry. */
|
|
244
|
+
input?: (context: WorkflowNodeContext) => MaybePromise<WorkflowInputOf<TWorkflow>>;
|
|
245
|
+
/** Optional direct definition that supplies the contract for a dynamic reference. */
|
|
246
|
+
contract?: TWorkflow;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export type WorkflowIncludeMap = Record<string, WorkflowIncludeDefinition>;
|
|
250
|
+
|
|
251
|
+
export type WorkflowIncludeExitReference<TIncludes extends WorkflowIncludeMap> = {
|
|
252
|
+
[K in keyof TIncludes & string]: TIncludes[K] extends WorkflowIncludeDefinition<infer TWorkflow>
|
|
253
|
+
? `${K}.${Extract<keyof WorkflowExitOutputs<TWorkflow>, string>}`
|
|
254
|
+
: never;
|
|
255
|
+
}[keyof TIncludes & string];
|
|
256
|
+
|
|
257
|
+
export type WorkflowTypedEdge<
|
|
258
|
+
TNodes extends Record<string, WorkflowNodeDefinition>,
|
|
259
|
+
TIncludes extends WorkflowIncludeMap,
|
|
260
|
+
> =
|
|
261
|
+
| {
|
|
262
|
+
from: (keyof TNodes & string) | WorkflowIncludeExitReference<TIncludes>;
|
|
263
|
+
to: (keyof TNodes & string) | (keyof TIncludes & string);
|
|
264
|
+
}
|
|
265
|
+
| {
|
|
266
|
+
from: keyof TNodes & string;
|
|
267
|
+
switch: {
|
|
268
|
+
on: string;
|
|
269
|
+
cases: Record<string, (keyof TNodes & string) | (keyof TIncludes & string)>;
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export type WorkflowDefinition<
|
|
274
|
+
TInput = any,
|
|
275
|
+
TExits extends WorkflowExitMap = WorkflowExitMap,
|
|
276
|
+
TIncludes extends WorkflowIncludeMap = WorkflowIncludeMap,
|
|
277
|
+
> = {
|
|
208
278
|
name: string;
|
|
279
|
+
/** Module URL used to attest directly imported child workflow files. */
|
|
280
|
+
source?: string;
|
|
281
|
+
/** Stable public input-and-exit contract identity for compatible overrides. */
|
|
282
|
+
contractId?: string;
|
|
283
|
+
/** Optional runtime input normalizer and validator. */
|
|
284
|
+
input?: WorkflowValueParser<TInput>;
|
|
209
285
|
/** Optional human-readable run title (static or derived from input). */
|
|
210
286
|
title?:
|
|
211
287
|
| string
|
|
212
|
-
| ((context: { input:
|
|
288
|
+
| ((context: { input: TInput; workflowName: string }) => MaybePromise<string | undefined>);
|
|
213
289
|
/**
|
|
214
290
|
* Optional instructions for a normal assistant response after the run ends.
|
|
215
291
|
* The Pi extension resolves this only after the final state is persisted;
|
|
@@ -220,6 +296,8 @@ export type WorkflowDefinition = {
|
|
|
220
296
|
| ((context: WorkflowPresentationContext) => MaybePromise<string | undefined>);
|
|
221
297
|
startAt: string;
|
|
222
298
|
nodes: Record<string, WorkflowNodeDefinition>;
|
|
299
|
+
includes?: TIncludes;
|
|
300
|
+
exits?: TExits;
|
|
223
301
|
edges: WorkflowEdge[];
|
|
224
302
|
/** Guard against unbounded loops. Defaults to the engine's maxSteps. */
|
|
225
303
|
maxSteps?: number;
|
|
@@ -308,6 +386,24 @@ export type WorkflowSource =
|
|
|
308
386
|
| { kind: "builtin"; id: string; revision: string }
|
|
309
387
|
| { kind: "file"; path: string; hash: string };
|
|
310
388
|
|
|
389
|
+
export type WorkflowMountedSource = {
|
|
390
|
+
mountPath: string[];
|
|
391
|
+
workflowName: string;
|
|
392
|
+
source: WorkflowSource;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
export type WorkflowMountSnapshot = {
|
|
396
|
+
mountPath: string[];
|
|
397
|
+
workflowName: string;
|
|
398
|
+
entryNode: string;
|
|
399
|
+
exits: Record<string, string>;
|
|
400
|
+
maxSteps?: number;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export type WorkflowCompositionSnapshot = {
|
|
404
|
+
mounts: WorkflowMountSnapshot[];
|
|
405
|
+
};
|
|
406
|
+
|
|
311
407
|
export type WorkflowRunState = {
|
|
312
408
|
schema: "pi-workflows.run-state.v1";
|
|
313
409
|
/**
|
|
@@ -329,6 +425,10 @@ export type WorkflowRunState = {
|
|
|
329
425
|
runTitle?: string;
|
|
330
426
|
/** Stable built-in identity or immutable file source used by this run. */
|
|
331
427
|
workflowSource?: WorkflowSource;
|
|
428
|
+
/** Sorted immutable sources used by included workflow mounts. */
|
|
429
|
+
workflowSources?: WorkflowMountedSource[];
|
|
430
|
+
/** SHA-256 of the fully resolved definition snapshot. */
|
|
431
|
+
definitionDigest?: string;
|
|
332
432
|
/** Legacy fields accepted only by the bounded built-in migration. */
|
|
333
433
|
workflowPath?: string;
|
|
334
434
|
workflowHash?: string;
|
|
@@ -360,14 +460,19 @@ export type WorkflowNodeSnapshot = {
|
|
|
360
460
|
summary?: string;
|
|
361
461
|
expectedOutput?: string;
|
|
362
462
|
actionExecution?: "function" | "shell";
|
|
463
|
+
mountPath?: string[];
|
|
464
|
+
localNodeId?: string;
|
|
465
|
+
includeTransition?: "entry" | "exit";
|
|
363
466
|
};
|
|
364
467
|
|
|
365
468
|
export type WorkflowDefinitionSnapshot = {
|
|
366
469
|
schema: "pi-workflows.definition-snapshot.v1";
|
|
367
470
|
name: string;
|
|
471
|
+
contractId?: string;
|
|
368
472
|
startAt: string;
|
|
369
473
|
nodes: Record<string, WorkflowNodeSnapshot>;
|
|
370
474
|
edges: WorkflowEdge[];
|
|
475
|
+
composition?: WorkflowCompositionSnapshot;
|
|
371
476
|
};
|
|
372
477
|
|
|
373
478
|
export type WorkflowTraceEvent = {
|
|
@@ -460,6 +565,8 @@ export type WorkflowRunManifest = {
|
|
|
460
565
|
workflowName: string;
|
|
461
566
|
runTitle?: string;
|
|
462
567
|
workflowSource?: WorkflowSource;
|
|
568
|
+
workflowSources?: WorkflowMountedSource[];
|
|
569
|
+
definitionDigest?: string;
|
|
463
570
|
startedAt: string;
|
|
464
571
|
finishedAt?: string;
|
|
465
572
|
status: WorkflowRunStatus;
|