@osolmaz/pi-workflows 0.1.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/extension/executor.d.ts +58 -0
  4. package/dist/extension/executor.js +201 -0
  5. package/dist/extension/executor.js.map +1 -0
  6. package/dist/extension/index.d.ts +17 -0
  7. package/dist/extension/index.js +504 -0
  8. package/dist/extension/index.js.map +1 -0
  9. package/dist/extension/widget.d.ts +21 -0
  10. package/dist/extension/widget.js +142 -0
  11. package/dist/extension/widget.js.map +1 -0
  12. package/dist/render/ansi.d.ts +16 -0
  13. package/dist/render/ansi.js +42 -0
  14. package/dist/render/ansi.js.map +1 -0
  15. package/dist/render/canvas.d.ts +40 -0
  16. package/dist/render/canvas.js +177 -0
  17. package/dist/render/canvas.js.map +1 -0
  18. package/dist/render/format.d.ts +3 -0
  19. package/dist/render/format.js +17 -0
  20. package/dist/render/format.js.map +1 -0
  21. package/dist/render/graph-render.d.ts +22 -0
  22. package/dist/render/graph-render.js +520 -0
  23. package/dist/render/graph-render.js.map +1 -0
  24. package/dist/render/graph.d.ts +46 -0
  25. package/dist/render/graph.js +272 -0
  26. package/dist/render/graph.js.map +1 -0
  27. package/dist/viewer/cli.d.ts +10 -0
  28. package/dist/viewer/cli.js +132 -0
  29. package/dist/viewer/cli.js.map +1 -0
  30. package/dist/viewer/render.d.ts +19 -0
  31. package/dist/viewer/render.js +162 -0
  32. package/dist/viewer/render.js.map +1 -0
  33. package/dist/viewer/tui.d.ts +11 -0
  34. package/dist/viewer/tui.js +140 -0
  35. package/dist/viewer/tui.js.map +1 -0
  36. package/dist/viewer/watch.d.ts +9 -0
  37. package/dist/viewer/watch.js +46 -0
  38. package/dist/viewer/watch.js.map +1 -0
  39. package/dist/workflows/decision.d.ts +25 -0
  40. package/dist/workflows/decision.js +96 -0
  41. package/dist/workflows/decision.js.map +1 -0
  42. package/dist/workflows/definition.d.ts +9 -0
  43. package/dist/workflows/definition.js +61 -0
  44. package/dist/workflows/definition.js.map +1 -0
  45. package/dist/workflows/engine.d.ts +65 -0
  46. package/dist/workflows/engine.js +574 -0
  47. package/dist/workflows/engine.js.map +1 -0
  48. package/dist/workflows/errors.d.ts +9 -0
  49. package/dist/workflows/errors.js +24 -0
  50. package/dist/workflows/errors.js.map +1 -0
  51. package/dist/workflows/graph.d.ts +17 -0
  52. package/dist/workflows/graph.js +127 -0
  53. package/dist/workflows/graph.js.map +1 -0
  54. package/dist/workflows/index.d.ts +11 -0
  55. package/dist/workflows/index.js +11 -0
  56. package/dist/workflows/index.js.map +1 -0
  57. package/dist/workflows/json.d.ts +14 -0
  58. package/dist/workflows/json.js +134 -0
  59. package/dist/workflows/json.js.map +1 -0
  60. package/dist/workflows/loader.d.ts +28 -0
  61. package/dist/workflows/loader.js +94 -0
  62. package/dist/workflows/loader.js.map +1 -0
  63. package/dist/workflows/schema.d.ts +7 -0
  64. package/dist/workflows/schema.js +176 -0
  65. package/dist/workflows/schema.js.map +1 -0
  66. package/dist/workflows/shell.d.ts +9 -0
  67. package/dist/workflows/shell.js +177 -0
  68. package/dist/workflows/shell.js.map +1 -0
  69. package/dist/workflows/store.d.ts +35 -0
  70. package/dist/workflows/store.js +181 -0
  71. package/dist/workflows/store.js.map +1 -0
  72. package/dist/workflows/text.d.ts +10 -0
  73. package/dist/workflows/text.js +32 -0
  74. package/dist/workflows/text.js.map +1 -0
  75. package/dist/workflows/types.d.ts +280 -0
  76. package/dist/workflows/types.js +2 -0
  77. package/dist/workflows/types.js.map +1 -0
  78. package/docs/development.md +130 -0
  79. package/docs/run-bundles.md +114 -0
  80. package/docs/workflows.md +311 -0
  81. package/examples/workflows/autoimplement.workflow.ts +92 -0
  82. package/examples/workflows/autoresearch.workflow.ts +139 -0
  83. package/examples/workflows/branch.workflow.ts +63 -0
  84. package/examples/workflows/echo.workflow.ts +23 -0
  85. package/examples/workflows/elegant-solution.workflow.ts +95 -0
  86. package/examples/workflows/shell.workflow.ts +31 -0
  87. package/examples/workflows/two-turn.workflow.ts +64 -0
  88. package/package.json +80 -0
  89. package/src/extension/executor.ts +251 -0
  90. package/src/extension/index.ts +627 -0
  91. package/src/extension/widget.ts +183 -0
  92. package/src/render/ansi.ts +47 -0
  93. package/src/render/canvas.ts +196 -0
  94. package/src/render/format.ts +19 -0
  95. package/src/render/graph-render.ts +738 -0
  96. package/src/render/graph.ts +341 -0
  97. package/src/viewer/cli.ts +150 -0
  98. package/src/viewer/render.ts +236 -0
  99. package/src/viewer/tui.ts +159 -0
  100. package/src/viewer/watch.ts +55 -0
  101. package/src/workflows/decision.ts +127 -0
  102. package/src/workflows/definition.ts +104 -0
  103. package/src/workflows/engine.ts +793 -0
  104. package/src/workflows/errors.ts +27 -0
  105. package/src/workflows/graph.ts +161 -0
  106. package/src/workflows/index.ts +76 -0
  107. package/src/workflows/json.ts +155 -0
  108. package/src/workflows/loader.ts +123 -0
  109. package/src/workflows/schema.ts +218 -0
  110. package/src/workflows/shell.ts +199 -0
  111. package/src/workflows/store.ts +234 -0
  112. package/src/workflows/text.ts +34 -0
  113. package/src/workflows/types.ts +318 -0
@@ -0,0 +1,236 @@
1
+ import { ansi, fitWidth, sanitizeText } from "../render/ansi.js";
2
+ import { formatDuration, runElapsedMs } from "../render/format.js";
3
+ import { renderGraphLines } from "../render/graph-render.js";
4
+ import type { LoadedRunBundle } from "../workflows/store.js";
5
+ import type { WorkflowRunStatus, WorkflowStepRecord } from "../workflows/types.js";
6
+
7
+ export { formatDuration, runElapsedMs };
8
+
9
+ export type ViewportSize = {
10
+ width: number;
11
+ height: number;
12
+ };
13
+
14
+ const STATUS_COLORS: Record<WorkflowRunStatus, (text: string) => string> = {
15
+ running: ansi.cyan,
16
+ waiting: ansi.yellow,
17
+ completed: ansi.green,
18
+ failed: ansi.red,
19
+ timed_out: ansi.red,
20
+ cancelled: ansi.yellow,
21
+ };
22
+
23
+ export function statusLabel(status: WorkflowRunStatus): string {
24
+ return STATUS_COLORS[status](status);
25
+ }
26
+
27
+ function previewValue(value: unknown, maxLength: number): string {
28
+ if (value === undefined) {
29
+ return "";
30
+ }
31
+ const text = typeof value === "string" ? value : JSON.stringify(value);
32
+ // Model-controlled values must not carry escape sequences into the terminal.
33
+ const singleLine = sanitizeText(text ?? "")
34
+ .replaceAll(/\s+/g, " ")
35
+ .trim();
36
+ return singleLine.length <= maxLength ? singleLine : `${singleLine.slice(0, maxLength - 1)}…`;
37
+ }
38
+
39
+ /** One line per run for the run picker. */
40
+ export function renderRunListLines(
41
+ bundles: LoadedRunBundle[],
42
+ selectedIndex: number,
43
+ size: ViewportSize,
44
+ now: Date = new Date(),
45
+ ): string[] {
46
+ const lines: string[] = [];
47
+ lines.push(ansi.bold("pi-workflows — runs"));
48
+ lines.push(ansi.dim("↑/↓ select · enter open · q quit"));
49
+ lines.push("");
50
+ if (bundles.length === 0) {
51
+ lines.push(ansi.dim("No workflow runs found."));
52
+ return lines.map((line) => fitWidth(line, size.width));
53
+ }
54
+ const visible = Math.max(1, size.height - lines.length - 1);
55
+ const start = Math.min(
56
+ Math.max(0, selectedIndex - Math.floor(visible / 2)),
57
+ Math.max(0, bundles.length - visible),
58
+ );
59
+ for (const [offset, bundle] of bundles.slice(start, start + visible).entries()) {
60
+ const index = start + offset;
61
+ const state = bundle.state;
62
+ const marker = index === selectedIndex ? ansi.cyan("›") : " ";
63
+ const elapsed = formatDuration(runElapsedMs(state, now));
64
+ const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
65
+ lines.push(
66
+ fitWidth(
67
+ `${marker} ${statusLabel(state.status)} ${ansi.bold(state.workflowName)}${title} ${ansi.dim(
68
+ `${state.runId} · ${elapsed}`,
69
+ )}`,
70
+ size.width,
71
+ ),
72
+ );
73
+ }
74
+ return lines;
75
+ }
76
+
77
+ function stepLine(
78
+ step: WorkflowStepRecord,
79
+ index: number,
80
+ selectedStepIndex: number,
81
+ width: number,
82
+ ): string {
83
+ const durationMs = Date.parse(step.finishedAt) - Date.parse(step.startedAt);
84
+ const glyph = step.outcome === "ok" ? ansi.green("✓") : ansi.red("✗");
85
+ const marker = index === selectedStepIndex ? ansi.cyan("›") : " ";
86
+ const preview =
87
+ step.error !== undefined
88
+ ? ansi.red(previewValue(step.error, 60))
89
+ : ansi.dim(previewValue(step.output, 60));
90
+ return fitWidth(
91
+ ` ${marker}${glyph} ${step.nodeId} ${ansi.dim(`(${step.nodeType}, ${formatDuration(durationMs)})`)} ${preview}`,
92
+ width,
93
+ );
94
+ }
95
+
96
+ /** Fallback node status list for bundles without a definition snapshot. */
97
+ function nodeStatusLine(bundle: LoadedRunBundle, nodeId: string, width: number, now: Date): string {
98
+ const state = bundle.state;
99
+ const nodeType = bundle.snapshot?.nodes[nodeId]?.nodeType ?? "?";
100
+ const result = state.results[nodeId];
101
+ let glyph = ansi.dim("·");
102
+ let suffix = "";
103
+ if (state.currentNode === nodeId) {
104
+ glyph = ansi.cyan("◐");
105
+ const startedAt = state.currentNodeStartedAt
106
+ ? Date.parse(state.currentNodeStartedAt)
107
+ : now.getTime();
108
+ const detail = state.statusDetail ? ` · ${sanitizeText(state.statusDetail)}` : "";
109
+ suffix = ansi.cyan(` running ${formatDuration(now.getTime() - startedAt)}${detail}`);
110
+ } else if (state.waitingOn === nodeId) {
111
+ glyph = ansi.yellow("⏸");
112
+ suffix = ansi.yellow(" waiting");
113
+ } else if (result) {
114
+ glyph = result.outcome === "ok" ? ansi.green("✓") : ansi.red("✗");
115
+ suffix = ansi.dim(` ${formatDuration(result.durationMs)}`);
116
+ }
117
+ return fitWidth(` ${glyph} ${nodeId} ${ansi.dim(`[${nodeType}]`)}${suffix}`, width);
118
+ }
119
+
120
+ /** Pretty-printed JSON body of the selected step for the inspector pane. */
121
+ function inspectorLines(step: WorkflowStepRecord, width: number): string[] {
122
+ const lines: string[] = [];
123
+ const body = step.error !== undefined ? step.error : step.output;
124
+ const rendered =
125
+ typeof body === "string" && step.error !== undefined ? body : JSON.stringify(body, null, 2);
126
+ for (const raw of (rendered ?? "null").split("\n")) {
127
+ lines.push(fitWidth(` ${sanitizeText(raw)}`, width));
128
+ }
129
+ if (step.action) {
130
+ const receipt = [
131
+ step.action.actionType,
132
+ step.action.command,
133
+ ...(step.action.args ?? []),
134
+ step.action.exitCode !== undefined ? `→ exit ${step.action.exitCode}` : "",
135
+ ]
136
+ .filter((part) => part !== undefined && part !== "")
137
+ .join(" ");
138
+ lines.push(fitWidth(ansi.dim(` ${sanitizeText(receipt)}`), width));
139
+ }
140
+ return lines;
141
+ }
142
+
143
+ /**
144
+ * Full-run detail view: header, graph pane, step timeline, inspector.
145
+ * `scroll` shifts the viewport down over the full body; `selectedStepIndex`
146
+ * scrubs the replay position (defaults to the latest step, i.e. live).
147
+ */
148
+ export function renderRunDetailLines(
149
+ bundle: LoadedRunBundle,
150
+ size: ViewportSize,
151
+ now: Date = new Date(),
152
+ scroll = 0,
153
+ selectedStepIndex: number | null = null,
154
+ ): string[] {
155
+ const state = bundle.state;
156
+ const steps = state.steps;
157
+ const selected = selectedStepIndex === null ? steps.length - 1 : selectedStepIndex;
158
+ const lines: string[] = [];
159
+ const title = state.runTitle ? ` — ${sanitizeText(state.runTitle)}` : "";
160
+ lines.push(
161
+ fitWidth(`${ansi.bold(`workflow ${sanitizeText(state.workflowName)}`)}${title}`, size.width),
162
+ );
163
+ const position =
164
+ selectedStepIndex === null || steps.length === 0
165
+ ? ""
166
+ : ` · step ${Math.min(selected, steps.length - 1) + 1}/${steps.length}`;
167
+ const paused = state.paused ? ` · ${ansi.yellow("paused")}` : "";
168
+ lines.push(
169
+ fitWidth(
170
+ `${statusLabel(state.status)}${paused} · run ${state.runId} · elapsed ${formatDuration(runElapsedMs(state, now))}${position}`,
171
+ size.width,
172
+ ),
173
+ );
174
+ lines.push(ansi.dim("q back · r refresh · ↑/↓ scroll · ←/→ replay steps"));
175
+ lines.push("");
176
+
177
+ const graph = renderGraphLines(bundle, selected, now, { nodeStyle: "box" }).map((line) =>
178
+ fitWidth(line, size.width),
179
+ );
180
+ if (graph.length > 0) {
181
+ lines.push(...graph);
182
+ } else {
183
+ // No definition snapshot: fall back to a flat executed-node list.
184
+ for (const nodeId of Object.keys(state.results)) {
185
+ lines.push(nodeStatusLine(bundle, nodeId, size.width, now));
186
+ }
187
+ }
188
+
189
+ if (steps.length > 0) {
190
+ lines.push("");
191
+ lines.push(ansi.bold("steps"));
192
+ for (const [index, step] of steps.entries()) {
193
+ lines.push(stepLine(step, index, Math.min(selected, steps.length - 1), size.width));
194
+ }
195
+ const inspected = steps[Math.min(Math.max(selected, 0), steps.length - 1)];
196
+ if (inspected) {
197
+ lines.push("");
198
+ lines.push(
199
+ ansi.bold(`step output — ${sanitizeText(inspected.nodeId)} (${inspected.outcome})`),
200
+ );
201
+ lines.push(...inspectorLines(inspected, size.width));
202
+ }
203
+ }
204
+
205
+ if (state.error) {
206
+ lines.push("");
207
+ lines.push(fitWidth(ansi.red(`error: ${sanitizeText(state.error)}`), size.width));
208
+ }
209
+ if (state.status === "completed" && state.finalOutput !== undefined) {
210
+ lines.push("");
211
+ lines.push(
212
+ fitWidth(
213
+ `${ansi.bold("output")} ${previewValue(state.finalOutput, size.width - 8)}`,
214
+ size.width,
215
+ ),
216
+ );
217
+ }
218
+ const start = Math.max(0, Math.min(scroll, lines.length - size.height));
219
+ return lines.slice(start, start + size.height);
220
+ }
221
+
222
+ /** Highest useful `scroll` value for the detail view of `bundle`. */
223
+ export function maxDetailScroll(
224
+ bundle: LoadedRunBundle,
225
+ size: ViewportSize,
226
+ selectedStepIndex: number | null = null,
227
+ ): number {
228
+ const total = renderRunDetailLines(
229
+ bundle,
230
+ { width: size.width, height: Number.MAX_SAFE_INTEGER },
231
+ new Date(),
232
+ 0,
233
+ selectedStepIndex,
234
+ ).length;
235
+ return Math.max(0, total - size.height);
236
+ }
@@ -0,0 +1,159 @@
1
+ import { listRunBundles, readRunBundle } from "../workflows/store.js";
2
+ import type { LoadedRunBundle } from "../workflows/store.js";
3
+ import {
4
+ maxDetailScroll,
5
+ renderRunDetailLines,
6
+ renderRunListLines,
7
+ type ViewportSize,
8
+ } from "./render.js";
9
+ import { watchRunsDir } from "./watch.js";
10
+
11
+ const ALT_SCREEN_ON = "\u001b[?1049h\u001b[?25l";
12
+ const ALT_SCREEN_OFF = "\u001b[?25h\u001b[?1049l";
13
+ const CLEAR = "\u001b[2J\u001b[H";
14
+
15
+ type ViewerMode = { view: "list" } | { view: "detail"; runDir: string };
16
+
17
+ export type ViewerOptions = {
18
+ runsDir: string;
19
+ runId?: string | undefined;
20
+ /** Redraw interval for elapsed timers while a run is active. */
21
+ tickMs?: number;
22
+ };
23
+
24
+ function viewportSize(): ViewportSize {
25
+ return {
26
+ width: process.stdout.columns ?? 80,
27
+ height: process.stdout.rows ?? 24,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Interactive live viewer. Watches the runs directory and re-renders as run
33
+ * bundles change on disk. Returns when the user quits.
34
+ */
35
+ export async function runViewer(options: ViewerOptions): Promise<void> {
36
+ let mode: ViewerMode = { view: "list" };
37
+ let bundles: LoadedRunBundle[] = [];
38
+ let selectedIndex = 0;
39
+ let detailScroll = 0;
40
+ /** Replay position; null follows the latest step live. */
41
+ let selectedStep: number | null = null;
42
+ let detailStepCount = 0;
43
+
44
+ if (options.runId) {
45
+ bundles = await listRunBundles(options.runsDir);
46
+ const match = bundles.find((bundle) => bundle.state.runId === options.runId);
47
+ if (!match) {
48
+ throw new Error(`Run not found: ${options.runId}`);
49
+ }
50
+ mode = { view: "detail", runDir: match.runDir };
51
+ }
52
+
53
+ const draw = async () => {
54
+ bundles = await listRunBundles(options.runsDir);
55
+ selectedIndex = Math.min(selectedIndex, Math.max(0, bundles.length - 1));
56
+ const size = viewportSize();
57
+ const lines =
58
+ mode.view === "list"
59
+ ? renderRunListLines(bundles, selectedIndex, size)
60
+ : await renderDetail(mode.runDir, size);
61
+ process.stdout.write(CLEAR + lines.join("\n"));
62
+ };
63
+
64
+ const renderDetail = async (runDir: string, size: ViewportSize): Promise<string[]> => {
65
+ const bundle = await readRunBundle(runDir);
66
+ if (!bundle) {
67
+ return ["Run bundle disappeared. Press q to go back."];
68
+ }
69
+ detailStepCount = bundle.state.steps.length;
70
+ if (selectedStep !== null && selectedStep >= detailStepCount - 1) {
71
+ // Scrubbed to (or past) the end: snap back to following live updates.
72
+ selectedStep = null;
73
+ }
74
+ detailScroll = Math.min(detailScroll, maxDetailScroll(bundle, size, selectedStep));
75
+ return renderRunDetailLines(bundle, size, new Date(), detailScroll, selectedStep);
76
+ };
77
+
78
+ process.stdout.write(ALT_SCREEN_ON);
79
+ const stopWatching = watchRunsDir(options.runsDir, () => {
80
+ void draw();
81
+ });
82
+ const ticker = setInterval(() => {
83
+ void draw();
84
+ }, options.tickMs ?? 1_000);
85
+
86
+ const rawModeSupported = process.stdin.isTTY === true;
87
+ if (rawModeSupported) {
88
+ process.stdin.setRawMode(true);
89
+ }
90
+ process.stdin.resume();
91
+
92
+ try {
93
+ await new Promise<void>((resolve) => {
94
+ const onKey = (data: Buffer) => {
95
+ const key = data.toString("utf8");
96
+ if (key === "q" || key === "\u0003" || key === "\u001b") {
97
+ if (mode.view === "detail" && key === "q") {
98
+ mode = { view: "list" };
99
+ void draw();
100
+ return;
101
+ }
102
+ resolve();
103
+ return;
104
+ }
105
+ handleNavigationKey(key);
106
+ };
107
+
108
+ const handleNavigationKey = (key: string) => {
109
+ if (mode.view !== "list") {
110
+ if (key === "r") {
111
+ void draw();
112
+ } else if (key === "\u001b[A" || key === "k") {
113
+ detailScroll = Math.max(0, detailScroll - 1);
114
+ void draw();
115
+ } else if (key === "\u001b[B" || key === "j") {
116
+ // Clamped against the content height in renderDetail.
117
+ detailScroll += 1;
118
+ void draw();
119
+ } else if (key === "\u001b[D" || key === "h") {
120
+ const current = selectedStep ?? detailStepCount - 1;
121
+ selectedStep = Math.max(0, current - 1);
122
+ void draw();
123
+ } else if (key === "\u001b[C" || key === "l") {
124
+ // renderDetail snaps back to live once this reaches the end.
125
+ selectedStep = selectedStep === null ? null : selectedStep + 1;
126
+ void draw();
127
+ }
128
+ return;
129
+ }
130
+ if (key === "\u001b[A" || key === "k") {
131
+ selectedIndex = Math.max(0, selectedIndex - 1);
132
+ void draw();
133
+ } else if (key === "\u001b[B" || key === "j") {
134
+ selectedIndex = Math.min(Math.max(0, bundles.length - 1), selectedIndex + 1);
135
+ void draw();
136
+ } else if (key === "\r" || key === "\n") {
137
+ const selected = bundles[selectedIndex];
138
+ if (selected) {
139
+ mode = { view: "detail", runDir: selected.runDir };
140
+ detailScroll = 0;
141
+ selectedStep = null;
142
+ void draw();
143
+ }
144
+ }
145
+ };
146
+
147
+ process.stdin.on("data", onKey);
148
+ void draw();
149
+ });
150
+ } finally {
151
+ clearInterval(ticker);
152
+ stopWatching();
153
+ if (rawModeSupported) {
154
+ process.stdin.setRawMode(false);
155
+ }
156
+ process.stdin.pause();
157
+ process.stdout.write(ALT_SCREEN_OFF);
158
+ }
159
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+
3
+ export type Unsubscribe = () => void;
4
+
5
+ /**
6
+ * Watch a directory tree for changes with a polling fallback. `onChange` is
7
+ * debounced so bursts of writes trigger one refresh.
8
+ */
9
+ export function watchRunsDir(
10
+ dir: string,
11
+ onChange: () => void,
12
+ options: { pollMs?: number; debounceMs?: number } = {},
13
+ ): Unsubscribe {
14
+ const pollMs = options.pollMs ?? 1_000;
15
+ const debounceMs = options.debounceMs ?? 80;
16
+ let debounceTimer: NodeJS.Timeout | null = null;
17
+ let closed = false;
18
+
19
+ const fire = () => {
20
+ if (closed) {
21
+ return;
22
+ }
23
+ if (debounceTimer) {
24
+ clearTimeout(debounceTimer);
25
+ }
26
+ debounceTimer = setTimeout(() => {
27
+ debounceTimer = null;
28
+ onChange();
29
+ }, debounceMs);
30
+ };
31
+
32
+ let watcher: fs.FSWatcher | null = null;
33
+ try {
34
+ watcher = fs.watch(dir, { recursive: true }, fire);
35
+ watcher.on("error", () => {
36
+ watcher?.close();
37
+ watcher = null;
38
+ });
39
+ } catch {
40
+ watcher = null;
41
+ }
42
+
43
+ // Polling fallback covers platforms without recursive fs.watch and missed events.
44
+ const poller = setInterval(fire, pollMs);
45
+ poller.unref?.();
46
+
47
+ return () => {
48
+ closed = true;
49
+ if (debounceTimer) {
50
+ clearTimeout(debounceTimer);
51
+ }
52
+ clearInterval(poller);
53
+ watcher?.close();
54
+ };
55
+ }
@@ -0,0 +1,127 @@
1
+ import { agent } from "./definition.js";
2
+ import { extractJsonValue } from "./json.js";
3
+ import type { AgentNodeDefinition, WorkflowEdge, WorkflowNodeContext } from "./types.js";
4
+
5
+ const DEFAULT_FIELD = "route";
6
+ const SIMPLE_FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
7
+
8
+ // All `agent` node fields except the ones the decision helper owns.
9
+ type DecisionAgentOptions = Omit<
10
+ AgentNodeDefinition,
11
+ "nodeType" | "prompt" | "expectedOutput" | "validate"
12
+ >;
13
+
14
+ export type DecisionDefinition<TChoice extends string> = DecisionAgentOptions & {
15
+ question: string | ((context: WorkflowNodeContext) => string | Promise<string>);
16
+ choices: readonly TChoice[];
17
+ field?: string;
18
+ };
19
+
20
+ /**
21
+ * Build an `agent` node that asks the model to pick one of `choices` and
22
+ * submit a JSON object whose chosen field is validated. Pair with
23
+ * `decisionEdge` (or any `switch` edge keyed on `$.<field>`) to route on the
24
+ * result.
25
+ */
26
+ export function decision<TChoice extends string>(
27
+ definition: DecisionDefinition<TChoice>,
28
+ ): AgentNodeDefinition {
29
+ const { question, choices, field: fieldOverride, ...agentOptions } = definition;
30
+ const field = normalizeField(fieldOverride);
31
+ assertValidChoices(choices);
32
+ const allowed = new Set<string>(choices);
33
+ const allowedLabels = choices.map((choice) => JSON.stringify(choice)).join(" | ");
34
+
35
+ return agent({
36
+ ...agentOptions,
37
+ async prompt(context) {
38
+ const text = typeof question === "function" ? await question(context) : question;
39
+ return [
40
+ text,
41
+ "",
42
+ `Answer by picking exactly one of: ${allowedLabels}.`,
43
+ `Include a short "reason" alongside your choice.`,
44
+ ].join("\n");
45
+ },
46
+ expectedOutput: `{ ${JSON.stringify(field)}: ${allowedLabels}, "reason": "short justification" }`,
47
+ validate(output) {
48
+ const raw = normalizeDecisionOutput(output);
49
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
50
+ throw new Error(`Decision output must be a JSON object, got ${describeValue(raw)}`);
51
+ }
52
+ const value = (raw as Record<string, unknown>)[field];
53
+ if (typeof value !== "string" || !allowed.has(value)) {
54
+ throw new Error(
55
+ `Decision returned invalid ${field}=${JSON.stringify(value)}; expected one of ${allowedLabels}`,
56
+ );
57
+ }
58
+ return raw;
59
+ },
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Build the matching `switch` edge for a `decision` node. Typing `cases` as
65
+ * `Record<TChoice, string>` makes a missing case a compile error.
66
+ */
67
+ export function decisionEdge<TChoice extends string>(args: {
68
+ from: string;
69
+ choices: readonly TChoice[];
70
+ field?: string;
71
+ cases: Record<TChoice, string>;
72
+ }): WorkflowEdge {
73
+ const field = normalizeField(args.field);
74
+ assertValidChoices(args.choices);
75
+ for (const choice of args.choices) {
76
+ if (!Object.hasOwn(args.cases, choice)) {
77
+ throw new Error(`Decision edge is missing case for choice ${JSON.stringify(choice)}`);
78
+ }
79
+ }
80
+ return {
81
+ from: args.from,
82
+ switch: {
83
+ on: `$.${field}`,
84
+ cases: args.cases,
85
+ },
86
+ };
87
+ }
88
+
89
+ function normalizeDecisionOutput(output: unknown): unknown {
90
+ if (typeof output === "string") {
91
+ return extractJsonValue(output);
92
+ }
93
+ return output;
94
+ }
95
+
96
+ function describeValue(value: unknown): string {
97
+ if (value === null) {
98
+ return "null";
99
+ }
100
+ return Array.isArray(value) ? "array" : typeof value;
101
+ }
102
+
103
+ function assertValidChoices(choices: readonly string[]): void {
104
+ if (choices.length === 0) {
105
+ throw new Error("Decision choices must include at least one value");
106
+ }
107
+ const seen = new Set<string>();
108
+ for (const choice of choices) {
109
+ if (typeof choice !== "string" || choice.length === 0) {
110
+ throw new Error("Decision choices must be non-empty strings");
111
+ }
112
+ if (seen.has(choice)) {
113
+ throw new Error(`Decision choices must be unique; duplicate ${JSON.stringify(choice)}`);
114
+ }
115
+ seen.add(choice);
116
+ }
117
+ }
118
+
119
+ function normalizeField(fieldOverride: string | undefined): string {
120
+ const field = fieldOverride ?? DEFAULT_FIELD;
121
+ if (!SIMPLE_FIELD_PATTERN.test(field)) {
122
+ throw new Error(
123
+ `Decision field must be a simple JSON key matching ${SIMPLE_FIELD_PATTERN.source}`,
124
+ );
125
+ }
126
+ return field;
127
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ assertValidAgentNode,
3
+ assertValidActionNode,
4
+ assertValidCheckpointNode,
5
+ assertValidComputeNode,
6
+ assertValidShellActionNode,
7
+ assertValidWorkflowDefinitionShape,
8
+ } from "./schema.js";
9
+ import type {
10
+ AgentNodeDefinition,
11
+ ActionNodeDefinition,
12
+ CheckpointNodeDefinition,
13
+ ComputeNodeDefinition,
14
+ FunctionActionNodeDefinition,
15
+ ShellActionNodeDefinition,
16
+ WorkflowDefinition,
17
+ } from "./types.js";
18
+
19
+ const WORKFLOW_DEFINITION_BRAND = Symbol.for("pi-workflows.definition");
20
+
21
+ export function defineWorkflow<TWorkflow extends WorkflowDefinition>(
22
+ definition: TWorkflow,
23
+ ): TWorkflow {
24
+ assertValidWorkflowDefinitionShape(definition);
25
+ if (isWorkflowDefinition(definition)) {
26
+ return definition;
27
+ }
28
+ Object.defineProperty(definition, WORKFLOW_DEFINITION_BRAND, {
29
+ value: true,
30
+ enumerable: false,
31
+ configurable: false,
32
+ writable: false,
33
+ });
34
+ return definition;
35
+ }
36
+
37
+ export function isWorkflowDefinition(value: unknown): value is WorkflowDefinition {
38
+ return (
39
+ value != null &&
40
+ typeof value === "object" &&
41
+ (value as Record<PropertyKey, unknown>)[WORKFLOW_DEFINITION_BRAND] === true
42
+ );
43
+ }
44
+
45
+ export function agent(definition: Omit<AgentNodeDefinition, "nodeType">): AgentNodeDefinition {
46
+ const node: AgentNodeDefinition = {
47
+ nodeType: "agent",
48
+ ...definition,
49
+ };
50
+ assertValidAgentNode(node);
51
+ return node;
52
+ }
53
+
54
+ export function compute(
55
+ definition: Omit<ComputeNodeDefinition, "nodeType">,
56
+ ): ComputeNodeDefinition {
57
+ const node: ComputeNodeDefinition = {
58
+ nodeType: "compute",
59
+ ...definition,
60
+ };
61
+ assertValidComputeNode(node);
62
+ return node;
63
+ }
64
+
65
+ export function action(
66
+ definition: Omit<FunctionActionNodeDefinition, "nodeType">,
67
+ ): FunctionActionNodeDefinition;
68
+ export function action(
69
+ definition: Omit<ShellActionNodeDefinition, "nodeType">,
70
+ ): ShellActionNodeDefinition;
71
+ export function action(
72
+ definition:
73
+ | Omit<FunctionActionNodeDefinition, "nodeType">
74
+ | Omit<ShellActionNodeDefinition, "nodeType">,
75
+ ): ActionNodeDefinition {
76
+ const node: ActionNodeDefinition = {
77
+ nodeType: "action",
78
+ ...definition,
79
+ };
80
+ assertValidActionNode(node);
81
+ return node;
82
+ }
83
+
84
+ export function shell(
85
+ definition: Omit<ShellActionNodeDefinition, "nodeType">,
86
+ ): ShellActionNodeDefinition {
87
+ const node: ShellActionNodeDefinition = {
88
+ nodeType: "action",
89
+ ...definition,
90
+ };
91
+ assertValidShellActionNode(node);
92
+ return node;
93
+ }
94
+
95
+ export function checkpoint(
96
+ definition: Omit<CheckpointNodeDefinition, "nodeType"> = {},
97
+ ): CheckpointNodeDefinition {
98
+ const node: CheckpointNodeDefinition = {
99
+ nodeType: "checkpoint",
100
+ ...definition,
101
+ };
102
+ assertValidCheckpointNode(node);
103
+ return node;
104
+ }