@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.
Files changed (78) hide show
  1. package/README.md +50 -16
  2. package/dist/builtins/autodevise.workflow.d.ts +58 -0
  3. package/dist/builtins/autodevise.workflow.js +190 -0
  4. package/dist/builtins/autodevise.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +154 -0
  6. package/dist/builtins/autoimplement.workflow.js +729 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/catalog.js +5 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +3 -0
  11. package/dist/builtins/index.js +4 -0
  12. package/dist/builtins/index.js.map +1 -0
  13. package/dist/builtins/monitor.workflow.d.ts +25 -3
  14. package/dist/builtins/monitor.workflow.js +200 -13
  15. package/dist/builtins/monitor.workflow.js.map +1 -1
  16. package/dist/extension/herdr-viewer.js +2 -6
  17. package/dist/extension/herdr-viewer.js.map +1 -1
  18. package/dist/render/graph-render.js +13 -2
  19. package/dist/render/graph-render.js.map +1 -1
  20. package/dist/workflows/catalog.d.ts +1 -0
  21. package/dist/workflows/catalog.js +6 -0
  22. package/dist/workflows/catalog.js.map +1 -1
  23. package/dist/workflows/composition.d.ts +45 -0
  24. package/dist/workflows/composition.js +471 -0
  25. package/dist/workflows/composition.js.map +1 -0
  26. package/dist/workflows/decision.d.ts +11 -5
  27. package/dist/workflows/decision.js.map +1 -1
  28. package/dist/workflows/definition.d.ts +22 -3
  29. package/dist/workflows/definition.js +46 -3
  30. package/dist/workflows/definition.js.map +1 -1
  31. package/dist/workflows/engine.js +115 -16
  32. package/dist/workflows/engine.js.map +1 -1
  33. package/dist/workflows/graph.js +8 -6
  34. package/dist/workflows/graph.js.map +1 -1
  35. package/dist/workflows/index.d.ts +3 -2
  36. package/dist/workflows/index.js +2 -1
  37. package/dist/workflows/index.js.map +1 -1
  38. package/dist/workflows/loader.d.ts +5 -4
  39. package/dist/workflows/loader.js +118 -18
  40. package/dist/workflows/loader.js.map +1 -1
  41. package/dist/workflows/schema.d.ts +3 -1
  42. package/dist/workflows/schema.js +49 -2
  43. package/dist/workflows/schema.js.map +1 -1
  44. package/dist/workflows/store.js +32 -2
  45. package/dist/workflows/store.js.map +1 -1
  46. package/dist/workflows/types.d.ts +77 -2
  47. package/docs/CONTROLLERS.md +1 -1
  48. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  49. package/docs/MONITOR.md +35 -18
  50. package/docs/WORKFLOW_COMPOSITION.md +326 -0
  51. package/docs/plans/2026-08-19-workflow-composition-plan.md +300 -0
  52. package/docs/run-bundles.md +24 -10
  53. package/docs/workflows.md +65 -12
  54. package/examples/workflows/autodevise.workflow.ts +1 -0
  55. package/examples/workflows/autoimplement.workflow.ts +1 -92
  56. package/herdr-plugin.toml +1 -1
  57. package/package.json +5 -1
  58. package/skills/monitor/SKILL.md +6 -1
  59. package/skills/pi-workflows/SKILL.md +3 -1
  60. package/src/builtins/autodevise.workflow.ts +231 -0
  61. package/src/builtins/autoimplement.workflow.ts +856 -0
  62. package/src/builtins/catalog.ts +5 -1
  63. package/src/builtins/index.ts +13 -0
  64. package/src/builtins/monitor.workflow.ts +242 -15
  65. package/src/extension/herdr-viewer.ts +1 -6
  66. package/src/render/graph-render.ts +14 -2
  67. package/src/workflows/catalog.ts +7 -0
  68. package/src/workflows/composition.ts +627 -0
  69. package/src/workflows/decision.ts +12 -5
  70. package/src/workflows/definition.ts +118 -8
  71. package/src/workflows/engine.ts +151 -18
  72. package/src/workflows/graph.ts +8 -6
  73. package/src/workflows/index.ts +20 -0
  74. package/src/workflows/loader.ts +186 -18
  75. package/src/workflows/schema.ts +62 -2
  76. package/src/workflows/store.ts +37 -2
  77. package/src/workflows/types.ts +109 -2
  78. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -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 { isWorkflowDefinition } from "./definition.js";
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 { WorkflowDefinition, WorkflowSource } from "./types.js";
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: { "@osolmaz/pi-workflows": SELF_ENTRY },
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 name, stable built-in ref, or direct user file path. */
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(options.cwd, ref);
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
- 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
- };
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
- /** Resolve an already persisted canonical source. */
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
- return catalog.resolve(source, runId);
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
- throw new WorkflowSourceChangedError(runId);
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 {
@@ -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(definition: WorkflowDefinition): void {
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
- if (!NODE_ID_PATTERN.test(nodeId)) {
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
  }
@@ -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]) => [nodeId, snapshotNode(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(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
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
  };
@@ -204,12 +204,88 @@ export type WorkflowPresentationContext = {
204
204
  signal: AbortSignal;
205
205
  };
206
206
 
207
- export type WorkflowDefinition = {
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: unknown; workflowName: string }) => MaybePromise<string | undefined>);
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;