@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.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/extension/executor.d.ts +58 -0
- package/dist/extension/executor.js +201 -0
- package/dist/extension/executor.js.map +1 -0
- package/dist/extension/index.d.ts +17 -0
- package/dist/extension/index.js +504 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/widget.d.ts +21 -0
- package/dist/extension/widget.js +142 -0
- package/dist/extension/widget.js.map +1 -0
- package/dist/render/ansi.d.ts +16 -0
- package/dist/render/ansi.js +42 -0
- package/dist/render/ansi.js.map +1 -0
- package/dist/render/canvas.d.ts +40 -0
- package/dist/render/canvas.js +177 -0
- package/dist/render/canvas.js.map +1 -0
- package/dist/render/format.d.ts +3 -0
- package/dist/render/format.js +17 -0
- package/dist/render/format.js.map +1 -0
- package/dist/render/graph-render.d.ts +22 -0
- package/dist/render/graph-render.js +520 -0
- package/dist/render/graph-render.js.map +1 -0
- package/dist/render/graph.d.ts +46 -0
- package/dist/render/graph.js +272 -0
- package/dist/render/graph.js.map +1 -0
- package/dist/viewer/cli.d.ts +10 -0
- package/dist/viewer/cli.js +132 -0
- package/dist/viewer/cli.js.map +1 -0
- package/dist/viewer/render.d.ts +19 -0
- package/dist/viewer/render.js +162 -0
- package/dist/viewer/render.js.map +1 -0
- package/dist/viewer/tui.d.ts +11 -0
- package/dist/viewer/tui.js +140 -0
- package/dist/viewer/tui.js.map +1 -0
- package/dist/viewer/watch.d.ts +9 -0
- package/dist/viewer/watch.js +46 -0
- package/dist/viewer/watch.js.map +1 -0
- package/dist/workflows/decision.d.ts +25 -0
- package/dist/workflows/decision.js +96 -0
- package/dist/workflows/decision.js.map +1 -0
- package/dist/workflows/definition.d.ts +9 -0
- package/dist/workflows/definition.js +61 -0
- package/dist/workflows/definition.js.map +1 -0
- package/dist/workflows/engine.d.ts +65 -0
- package/dist/workflows/engine.js +574 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/errors.d.ts +9 -0
- package/dist/workflows/errors.js +24 -0
- package/dist/workflows/errors.js.map +1 -0
- package/dist/workflows/graph.d.ts +17 -0
- package/dist/workflows/graph.js +127 -0
- package/dist/workflows/graph.js.map +1 -0
- package/dist/workflows/index.d.ts +11 -0
- package/dist/workflows/index.js +11 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/json.d.ts +14 -0
- package/dist/workflows/json.js +134 -0
- package/dist/workflows/json.js.map +1 -0
- package/dist/workflows/loader.d.ts +28 -0
- package/dist/workflows/loader.js +94 -0
- package/dist/workflows/loader.js.map +1 -0
- package/dist/workflows/schema.d.ts +7 -0
- package/dist/workflows/schema.js +176 -0
- package/dist/workflows/schema.js.map +1 -0
- package/dist/workflows/shell.d.ts +9 -0
- package/dist/workflows/shell.js +177 -0
- package/dist/workflows/shell.js.map +1 -0
- package/dist/workflows/store.d.ts +35 -0
- package/dist/workflows/store.js +181 -0
- package/dist/workflows/store.js.map +1 -0
- package/dist/workflows/text.d.ts +10 -0
- package/dist/workflows/text.js +32 -0
- package/dist/workflows/text.js.map +1 -0
- package/dist/workflows/types.d.ts +280 -0
- package/dist/workflows/types.js +2 -0
- package/dist/workflows/types.js.map +1 -0
- package/docs/development.md +130 -0
- package/docs/run-bundles.md +114 -0
- package/docs/workflows.md +311 -0
- package/examples/workflows/autoimplement.workflow.ts +92 -0
- package/examples/workflows/autoresearch.workflow.ts +139 -0
- package/examples/workflows/branch.workflow.ts +63 -0
- package/examples/workflows/echo.workflow.ts +23 -0
- package/examples/workflows/elegant-solution.workflow.ts +95 -0
- package/examples/workflows/shell.workflow.ts +31 -0
- package/examples/workflows/two-turn.workflow.ts +64 -0
- package/package.json +80 -0
- package/src/extension/executor.ts +251 -0
- package/src/extension/index.ts +627 -0
- package/src/extension/widget.ts +183 -0
- package/src/render/ansi.ts +47 -0
- package/src/render/canvas.ts +196 -0
- package/src/render/format.ts +19 -0
- package/src/render/graph-render.ts +738 -0
- package/src/render/graph.ts +341 -0
- package/src/viewer/cli.ts +150 -0
- package/src/viewer/render.ts +236 -0
- package/src/viewer/tui.ts +159 -0
- package/src/viewer/watch.ts +55 -0
- package/src/workflows/decision.ts +127 -0
- package/src/workflows/definition.ts +104 -0
- package/src/workflows/engine.ts +793 -0
- package/src/workflows/errors.ts +27 -0
- package/src/workflows/graph.ts +161 -0
- package/src/workflows/index.ts +76 -0
- package/src/workflows/json.ts +155 -0
- package/src/workflows/loader.ts +123 -0
- package/src/workflows/schema.ts +218 -0
- package/src/workflows/shell.ts +199 -0
- package/src/workflows/store.ts +234 -0
- package/src/workflows/text.ts +34 -0
- package/src/workflows/types.ts +318 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentNodeDefinition,
|
|
3
|
+
ActionNodeDefinition,
|
|
4
|
+
CheckpointNodeDefinition,
|
|
5
|
+
ComputeNodeDefinition,
|
|
6
|
+
FunctionActionNodeDefinition,
|
|
7
|
+
ShellActionNodeDefinition,
|
|
8
|
+
WorkflowDefinition,
|
|
9
|
+
WorkflowEdge,
|
|
10
|
+
WorkflowNodeDefinition,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
const NODE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
14
|
+
|
|
15
|
+
function fail(message: string): never {
|
|
16
|
+
throw new Error(`Invalid workflow definition: ${message}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertRecord(
|
|
20
|
+
value: unknown,
|
|
21
|
+
description: string,
|
|
22
|
+
): asserts value is Record<string, unknown> {
|
|
23
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
fail(`${description} must be an object`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertOptionalFunction(value: unknown, description: string): void {
|
|
29
|
+
if (value !== undefined && typeof value !== "function") {
|
|
30
|
+
fail(`${description} must be a function when provided`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertCommonNodeFields(node: WorkflowNodeDefinition, nodeId: string): void {
|
|
35
|
+
if (
|
|
36
|
+
node.timeoutMs !== undefined &&
|
|
37
|
+
(typeof node.timeoutMs !== "number" || !Number.isFinite(node.timeoutMs) || node.timeoutMs <= 0)
|
|
38
|
+
) {
|
|
39
|
+
fail(`node ${nodeId} timeoutMs must be a finite positive number`);
|
|
40
|
+
}
|
|
41
|
+
if (node.statusDetail !== undefined && typeof node.statusDetail !== "string") {
|
|
42
|
+
fail(`node ${nodeId} statusDetail must be a string`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertValidAgentNode(node: AgentNodeDefinition, nodeId = "agent"): void {
|
|
47
|
+
if (typeof node.prompt !== "function") {
|
|
48
|
+
fail(`node ${nodeId} requires a prompt function`);
|
|
49
|
+
}
|
|
50
|
+
if (node.expectedOutput !== undefined && typeof node.expectedOutput !== "string") {
|
|
51
|
+
fail(`node ${nodeId} expectedOutput must be a string`);
|
|
52
|
+
}
|
|
53
|
+
assertOptionalFunction(node.validate, `node ${nodeId} validate`);
|
|
54
|
+
assertCommonNodeFields(node, nodeId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function assertValidComputeNode(node: ComputeNodeDefinition, nodeId = "compute"): void {
|
|
58
|
+
if (typeof node.run !== "function") {
|
|
59
|
+
fail(`node ${nodeId} requires a run function`);
|
|
60
|
+
}
|
|
61
|
+
assertCommonNodeFields(node, nodeId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function assertValidActionNode(node: ActionNodeDefinition, nodeId = "action"): void {
|
|
65
|
+
// Dispatch discriminates with `"exec" in node`, so validation must use the
|
|
66
|
+
// same property semantics: a present-but-invalid `exec` is an error even
|
|
67
|
+
// when a `run` function exists.
|
|
68
|
+
const hasExec = "exec" in node;
|
|
69
|
+
const hasRun = "run" in node;
|
|
70
|
+
if (hasExec === hasRun) {
|
|
71
|
+
fail(`node ${nodeId} requires exactly one of run or exec`);
|
|
72
|
+
}
|
|
73
|
+
if (hasExec) {
|
|
74
|
+
if (typeof node.exec !== "function") {
|
|
75
|
+
fail(`node ${nodeId} exec must be a function`);
|
|
76
|
+
}
|
|
77
|
+
assertOptionalFunction((node as ShellActionNodeDefinition).parse, `node ${nodeId} parse`);
|
|
78
|
+
} else if (typeof (node as FunctionActionNodeDefinition).run !== "function") {
|
|
79
|
+
fail(`node ${nodeId} run must be a function`);
|
|
80
|
+
}
|
|
81
|
+
assertCommonNodeFields(node, nodeId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function assertValidShellActionNode(
|
|
85
|
+
node: ShellActionNodeDefinition,
|
|
86
|
+
nodeId = "shell",
|
|
87
|
+
): void {
|
|
88
|
+
if (typeof node.exec !== "function") {
|
|
89
|
+
fail(`node ${nodeId} requires an exec function`);
|
|
90
|
+
}
|
|
91
|
+
assertOptionalFunction(node.parse, `node ${nodeId} parse`);
|
|
92
|
+
assertCommonNodeFields(node, nodeId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function assertValidCheckpointNode(
|
|
96
|
+
node: CheckpointNodeDefinition,
|
|
97
|
+
nodeId = "checkpoint",
|
|
98
|
+
): void {
|
|
99
|
+
if (node.summary !== undefined && typeof node.summary !== "string") {
|
|
100
|
+
fail(`node ${nodeId} summary must be a string`);
|
|
101
|
+
}
|
|
102
|
+
assertOptionalFunction(node.run, `node ${nodeId} run`);
|
|
103
|
+
assertCommonNodeFields(node, nodeId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assertValidNode(node: WorkflowNodeDefinition, nodeId: string): void {
|
|
107
|
+
switch (node.nodeType) {
|
|
108
|
+
case "agent":
|
|
109
|
+
assertValidAgentNode(node, nodeId);
|
|
110
|
+
return;
|
|
111
|
+
case "compute":
|
|
112
|
+
assertValidComputeNode(node, nodeId);
|
|
113
|
+
return;
|
|
114
|
+
case "action":
|
|
115
|
+
assertValidActionNode(node, nodeId);
|
|
116
|
+
return;
|
|
117
|
+
case "checkpoint":
|
|
118
|
+
assertValidCheckpointNode(node, nodeId);
|
|
119
|
+
return;
|
|
120
|
+
default:
|
|
121
|
+
fail(
|
|
122
|
+
`node ${nodeId} has unknown nodeType ${String((node as { nodeType?: unknown }).nodeType)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertValidEdgeShape(edge: WorkflowEdge, index: number): void {
|
|
128
|
+
assertRecord(edge, `edge ${index}`);
|
|
129
|
+
if (typeof edge.from !== "string" || edge.from.length === 0) {
|
|
130
|
+
fail(`edge ${index} requires a from node id`);
|
|
131
|
+
}
|
|
132
|
+
if ("to" in edge) {
|
|
133
|
+
if (typeof edge.to !== "string" || edge.to.length === 0) {
|
|
134
|
+
fail(`edge ${index} requires a to node id`);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
assertRecord(edge.switch, `edge ${index} switch`);
|
|
139
|
+
if (typeof edge.switch.on !== "string" || edge.switch.on.length === 0) {
|
|
140
|
+
fail(`edge ${index} switch.on must be a JSON path string`);
|
|
141
|
+
}
|
|
142
|
+
// Routing only understands these prefixes; rejecting others here prevents
|
|
143
|
+
// the source node from executing its side effects before a routing error.
|
|
144
|
+
const on = edge.switch.on;
|
|
145
|
+
if (!on.startsWith("$.") && !on.startsWith("$output.") && !on.startsWith("$result.")) {
|
|
146
|
+
fail(`edge ${index} switch.on must start with "$.", "$output.", or "$result."`);
|
|
147
|
+
}
|
|
148
|
+
assertRecord(edge.switch.cases, `edge ${index} switch.cases`);
|
|
149
|
+
if (Object.keys(edge.switch.cases).length === 0) {
|
|
150
|
+
fail(`edge ${index} switch.cases must not be empty`);
|
|
151
|
+
}
|
|
152
|
+
for (const [caseKey, target] of Object.entries(edge.switch.cases)) {
|
|
153
|
+
if (typeof target !== "string" || target.length === 0) {
|
|
154
|
+
fail(`edge ${index} switch case ${JSON.stringify(caseKey)} must map to a node id`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Names claimed by `/workflow` subcommands; a workflow with one of these
|
|
161
|
+
* names could never be started because the keyword wins the argument slot.
|
|
162
|
+
*/
|
|
163
|
+
const RESERVED_WORKFLOW_NAMES = new Set(["cancel", "list", "pause", "resume"]);
|
|
164
|
+
|
|
165
|
+
export function assertValidWorkflowDefinitionShape(definition: WorkflowDefinition): void {
|
|
166
|
+
assertRecord(definition, "workflow");
|
|
167
|
+
if (typeof definition.name !== "string" || definition.name.length === 0) {
|
|
168
|
+
fail("workflow requires a name");
|
|
169
|
+
}
|
|
170
|
+
if (RESERVED_WORKFLOW_NAMES.has(definition.name)) {
|
|
171
|
+
fail(`workflow name ${JSON.stringify(definition.name)} is reserved for /workflow subcommands`);
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
definition.title !== undefined &&
|
|
175
|
+
typeof definition.title !== "string" &&
|
|
176
|
+
typeof definition.title !== "function"
|
|
177
|
+
) {
|
|
178
|
+
fail("workflow title must be a string or function");
|
|
179
|
+
}
|
|
180
|
+
if (
|
|
181
|
+
definition.presentationPrompt !== undefined &&
|
|
182
|
+
typeof definition.presentationPrompt !== "string" &&
|
|
183
|
+
typeof definition.presentationPrompt !== "function"
|
|
184
|
+
) {
|
|
185
|
+
fail("workflow presentationPrompt must be a string or function");
|
|
186
|
+
}
|
|
187
|
+
if (typeof definition.startAt !== "string" || definition.startAt.length === 0) {
|
|
188
|
+
fail("workflow requires startAt");
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
definition.maxSteps !== undefined &&
|
|
192
|
+
(typeof definition.maxSteps !== "number" ||
|
|
193
|
+
!Number.isInteger(definition.maxSteps) ||
|
|
194
|
+
definition.maxSteps <= 0)
|
|
195
|
+
) {
|
|
196
|
+
fail("workflow maxSteps must be a positive integer");
|
|
197
|
+
}
|
|
198
|
+
assertRecord(definition.nodes, "workflow nodes");
|
|
199
|
+
if (Object.keys(definition.nodes).length === 0) {
|
|
200
|
+
fail("workflow requires at least one node");
|
|
201
|
+
}
|
|
202
|
+
for (const [nodeId, node] of Object.entries(definition.nodes)) {
|
|
203
|
+
if (!NODE_ID_PATTERN.test(nodeId)) {
|
|
204
|
+
fail(`node id ${JSON.stringify(nodeId)} must match ${NODE_ID_PATTERN.source}`);
|
|
205
|
+
}
|
|
206
|
+
// Ids like __proto__ or toString would collide with Object prototype
|
|
207
|
+
// members in the plain-object maps used for outputs and results.
|
|
208
|
+
if (nodeId in Object.prototype) {
|
|
209
|
+
fail(`node id ${JSON.stringify(nodeId)} shadows an Object prototype member`);
|
|
210
|
+
}
|
|
211
|
+
assertRecord(node, `node ${nodeId}`);
|
|
212
|
+
assertValidNode(node, nodeId);
|
|
213
|
+
}
|
|
214
|
+
if (!Array.isArray(definition.edges)) {
|
|
215
|
+
fail("workflow edges must be an array");
|
|
216
|
+
}
|
|
217
|
+
definition.edges.forEach(assertValidEdgeShape);
|
|
218
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { CancelledError, TimeoutError } from "./errors.js";
|
|
3
|
+
import type { ShellActionExecution, ShellActionResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** Default cap on captured stdout/stderr, each. */
|
|
6
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 1_000_000;
|
|
7
|
+
const TRUNCATION_MARKER = "\n…[output truncated]";
|
|
8
|
+
|
|
9
|
+
const SHELL_RESULT = Symbol.for("pi-workflows.shell-result");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Retrieve the shell result attached to an error thrown by
|
|
13
|
+
* {@link runShellAction}, so callers can persist the action receipt even when
|
|
14
|
+
* the command failed.
|
|
15
|
+
*/
|
|
16
|
+
export function shellResultFromError(error: unknown): ShellActionResult | null {
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
const attached = (error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT];
|
|
19
|
+
return attached ?? null;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderShellCommand(command: string, args: string[]): string {
|
|
25
|
+
const renderedArgs = args.map((arg) => JSON.stringify(arg)).join(" ");
|
|
26
|
+
return renderedArgs.length > 0 ? `${command} ${renderedArgs}` : command;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shellFailure(
|
|
30
|
+
spec: ShellActionExecution,
|
|
31
|
+
args: string[],
|
|
32
|
+
result: ShellActionResult,
|
|
33
|
+
killedBy: "timeout" | "abort" | null,
|
|
34
|
+
): Error | undefined {
|
|
35
|
+
if (killedBy === "timeout") {
|
|
36
|
+
return new TimeoutError(spec.timeoutMs ?? 0);
|
|
37
|
+
}
|
|
38
|
+
if (killedBy === "abort") {
|
|
39
|
+
return new CancelledError();
|
|
40
|
+
}
|
|
41
|
+
if (((result.exitCode ?? 0) !== 0 || result.signal != null) && spec.allowNonZeroExit !== true) {
|
|
42
|
+
const status = result.signal ? `signal ${result.signal}` : `exit ${String(result.exitCode)}`;
|
|
43
|
+
const details = result.stderr.length > 0 ? `\n${result.stderr.trim()}` : "";
|
|
44
|
+
return new Error(
|
|
45
|
+
`Shell action failed (${renderShellCommand(spec.command, args)}): ${status}${details}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runShellAction(
|
|
52
|
+
spec: ShellActionExecution,
|
|
53
|
+
signal?: AbortSignal,
|
|
54
|
+
): Promise<ShellActionResult> {
|
|
55
|
+
// The node may have been cancelled while an async `exec` callback resolved;
|
|
56
|
+
// never start side effects for an already-abandoned attempt.
|
|
57
|
+
if (signal?.aborted) {
|
|
58
|
+
throw new CancelledError();
|
|
59
|
+
}
|
|
60
|
+
const cwd = spec.cwd ?? process.cwd();
|
|
61
|
+
const args = spec.args ?? [];
|
|
62
|
+
const startMs = Date.now();
|
|
63
|
+
// POSIX: run in its own process group so timeout/abort can kill the whole
|
|
64
|
+
// tree, including descendants that inherited the stdio pipes.
|
|
65
|
+
const useProcessGroup = process.platform !== "win32";
|
|
66
|
+
const child = spawn(spec.command, args, {
|
|
67
|
+
cwd,
|
|
68
|
+
env: { ...process.env, ...spec.env },
|
|
69
|
+
shell: spec.shell,
|
|
70
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
detached: useProcessGroup,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
let stdout = "";
|
|
76
|
+
let stderr = "";
|
|
77
|
+
let killedBy: "timeout" | "abort" | null = null;
|
|
78
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
79
|
+
|
|
80
|
+
// Cap retained output so a verbose or unending command cannot exhaust
|
|
81
|
+
// memory before its timeout fires.
|
|
82
|
+
const maxOutputChars = spec.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
83
|
+
const appendCapped = (current: string, chunk: string): string => {
|
|
84
|
+
if (current.endsWith(TRUNCATION_MARKER)) {
|
|
85
|
+
return current;
|
|
86
|
+
}
|
|
87
|
+
const room = maxOutputChars - current.length;
|
|
88
|
+
if (chunk.length <= room) {
|
|
89
|
+
return current + chunk;
|
|
90
|
+
}
|
|
91
|
+
return current + chunk.slice(0, Math.max(0, room)) + TRUNCATION_MARKER;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const signalTree = (killSignal: NodeJS.Signals) => {
|
|
95
|
+
if (useProcessGroup && child.pid !== undefined) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(-child.pid, killSignal);
|
|
98
|
+
return;
|
|
99
|
+
} catch {
|
|
100
|
+
// Group already gone; fall back to the direct child.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
child.kill(killSignal);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let killEscalation: NodeJS.Timeout | undefined;
|
|
107
|
+
const kill = (reason: "timeout" | "abort") => {
|
|
108
|
+
killedBy ??= reason;
|
|
109
|
+
signalTree("SIGTERM");
|
|
110
|
+
killEscalation ??= setTimeout(() => {
|
|
111
|
+
signalTree("SIGKILL");
|
|
112
|
+
}, 1_000);
|
|
113
|
+
killEscalation.unref();
|
|
114
|
+
};
|
|
115
|
+
const onAbort = () => kill("abort");
|
|
116
|
+
|
|
117
|
+
const finish = new Promise<ShellActionResult>((resolve, reject) => {
|
|
118
|
+
child.stdout.setEncoding("utf8");
|
|
119
|
+
child.stderr.setEncoding("utf8");
|
|
120
|
+
child.stdout.on("data", (chunk: string) => {
|
|
121
|
+
stdout = appendCapped(stdout, chunk);
|
|
122
|
+
});
|
|
123
|
+
child.stderr.on("data", (chunk: string) => {
|
|
124
|
+
stderr = appendCapped(stderr, chunk);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
child.once("error", (error) => {
|
|
128
|
+
// Spawn failures (missing executable, EACCES) still get a receipt so
|
|
129
|
+
// failed actions remain auditable.
|
|
130
|
+
const result: ShellActionResult = {
|
|
131
|
+
command: spec.command,
|
|
132
|
+
args,
|
|
133
|
+
cwd,
|
|
134
|
+
stdout,
|
|
135
|
+
stderr,
|
|
136
|
+
exitCode: null,
|
|
137
|
+
signal: null,
|
|
138
|
+
durationMs: Date.now() - startMs,
|
|
139
|
+
};
|
|
140
|
+
(error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result;
|
|
141
|
+
reject(error);
|
|
142
|
+
});
|
|
143
|
+
// `close` (unlike `exit`) fires only after stdio has fully closed, so
|
|
144
|
+
// captured output is never truncated.
|
|
145
|
+
child.once("close", (exitCode, signalName) => {
|
|
146
|
+
const result: ShellActionResult = {
|
|
147
|
+
command: spec.command,
|
|
148
|
+
args,
|
|
149
|
+
cwd,
|
|
150
|
+
stdout,
|
|
151
|
+
stderr,
|
|
152
|
+
exitCode,
|
|
153
|
+
signal: signalName,
|
|
154
|
+
durationMs: Date.now() - startMs,
|
|
155
|
+
};
|
|
156
|
+
const error = shellFailure(spec, args, result, killedBy);
|
|
157
|
+
if (error) {
|
|
158
|
+
// Attach the result so callers can persist the action receipt.
|
|
159
|
+
(error as Error & { [SHELL_RESULT]?: ShellActionResult })[SHELL_RESULT] = result;
|
|
160
|
+
reject(error);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
resolve(result);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// A child that exits without consuming stdin emits EPIPE; without a
|
|
168
|
+
// listener that would crash the whole process instead of just this action.
|
|
169
|
+
child.stdin.on("error", () => {});
|
|
170
|
+
if (spec.stdin != null) {
|
|
171
|
+
child.stdin.write(spec.stdin);
|
|
172
|
+
}
|
|
173
|
+
child.stdin.end();
|
|
174
|
+
|
|
175
|
+
if (spec.timeoutMs != null && spec.timeoutMs > 0) {
|
|
176
|
+
timeout = setTimeout(() => kill("timeout"), spec.timeoutMs);
|
|
177
|
+
}
|
|
178
|
+
if (signal) {
|
|
179
|
+
if (signal.aborted) {
|
|
180
|
+
onAbort();
|
|
181
|
+
} else {
|
|
182
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
return await finish;
|
|
188
|
+
} finally {
|
|
189
|
+
if (timeout) {
|
|
190
|
+
clearTimeout(timeout);
|
|
191
|
+
}
|
|
192
|
+
if (killEscalation) {
|
|
193
|
+
// The group pid may be reused after the child exits; never let the
|
|
194
|
+
// delayed SIGKILL fire once the command has fully closed.
|
|
195
|
+
clearTimeout(killEscalation);
|
|
196
|
+
}
|
|
197
|
+
signal?.removeEventListener("abort", onAbort);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
WorkflowDefinition,
|
|
7
|
+
WorkflowDefinitionSnapshot,
|
|
8
|
+
WorkflowNodeDefinition,
|
|
9
|
+
WorkflowNodeSnapshot,
|
|
10
|
+
WorkflowRunManifest,
|
|
11
|
+
WorkflowRunState,
|
|
12
|
+
WorkflowTraceEvent,
|
|
13
|
+
WorkflowTraceEventDraft,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
export const RUN_BUNDLE_SCHEMA = "pi-workflows.run-bundle.v1" as const;
|
|
17
|
+
export const TRACE_EVENT_SCHEMA = "pi-workflows.trace-event.v1" as const;
|
|
18
|
+
export const DEFINITION_SNAPSHOT_SCHEMA = "pi-workflows.definition-snapshot.v1" as const;
|
|
19
|
+
|
|
20
|
+
const MANIFEST_PATH = "manifest.json";
|
|
21
|
+
const WORKFLOW_SNAPSHOT_PATH = "workflow.json";
|
|
22
|
+
const STATE_PATH = "state.json";
|
|
23
|
+
const TRACE_PATH = "trace.ndjson";
|
|
24
|
+
|
|
25
|
+
/** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
|
|
26
|
+
export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
|
|
27
|
+
const override = process.env.PI_WORKFLOWS_RUNS_DIR;
|
|
28
|
+
if (override !== undefined && override.length > 0) {
|
|
29
|
+
return override;
|
|
30
|
+
}
|
|
31
|
+
return path.join(homeDir, ".pi", "agent", "workflows", "runs");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createRunId(workflowName: string, now: Date = new Date()): string {
|
|
35
|
+
const stamp = now
|
|
36
|
+
.toISOString()
|
|
37
|
+
.replaceAll(/[-:]/g, "")
|
|
38
|
+
.replace(/\.\d+Z$/, "Z");
|
|
39
|
+
const slug = workflowName
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replaceAll(/(^-|-$)/g, "")
|
|
43
|
+
.slice(0, 40);
|
|
44
|
+
return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Persists run bundles. A bundle directory contains `manifest.json`,
|
|
49
|
+
* `workflow.json` (definition snapshot), `state.json` (full run projection,
|
|
50
|
+
* atomically replaced), and `trace.ndjson` (append-only event log).
|
|
51
|
+
*/
|
|
52
|
+
export class WorkflowRunStore {
|
|
53
|
+
readonly outputRoot: string;
|
|
54
|
+
private readonly traceSeqByRun = new Map<string, number>();
|
|
55
|
+
private readonly appendChainByPath = new Map<string, Promise<void>>();
|
|
56
|
+
|
|
57
|
+
constructor(outputRoot: string = workflowRunsBaseDir()) {
|
|
58
|
+
this.outputRoot = outputRoot;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
runDirFor(runId: string): string {
|
|
62
|
+
return path.join(this.outputRoot, runId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async initializeRunBundle(
|
|
66
|
+
workflow: WorkflowDefinition,
|
|
67
|
+
state: WorkflowRunState,
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
const runDir = this.runDirFor(state.runId);
|
|
70
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
71
|
+
this.traceSeqByRun.set(runDir, 0);
|
|
72
|
+
|
|
73
|
+
await writeJsonAtomic(
|
|
74
|
+
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
75
|
+
createDefinitionSnapshot(workflow),
|
|
76
|
+
);
|
|
77
|
+
await writeJsonAtomic(path.join(runDir, MANIFEST_PATH), createManifest(state));
|
|
78
|
+
await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
|
|
79
|
+
await this.appendJsonLine(path.join(runDir, TRACE_PATH), null);
|
|
80
|
+
|
|
81
|
+
return runDir;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async writeSnapshot(
|
|
85
|
+
runDir: string,
|
|
86
|
+
state: WorkflowRunState,
|
|
87
|
+
event: WorkflowTraceEventDraft,
|
|
88
|
+
): Promise<WorkflowTraceEvent> {
|
|
89
|
+
state.updatedAt = new Date().toISOString();
|
|
90
|
+
await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
|
|
91
|
+
await writeJsonAtomic(path.join(runDir, MANIFEST_PATH), createManifest(state));
|
|
92
|
+
return await this.appendTrace(runDir, state, event);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async appendTrace(
|
|
96
|
+
runDir: string,
|
|
97
|
+
state: WorkflowRunState,
|
|
98
|
+
event: WorkflowTraceEventDraft,
|
|
99
|
+
): Promise<WorkflowTraceEvent> {
|
|
100
|
+
const traceEvent: WorkflowTraceEvent = {
|
|
101
|
+
seq: this.nextTraceSeq(runDir),
|
|
102
|
+
at: new Date().toISOString(),
|
|
103
|
+
runId: state.runId,
|
|
104
|
+
...event,
|
|
105
|
+
};
|
|
106
|
+
await this.appendJsonLine(path.join(runDir, TRACE_PATH), traceEvent);
|
|
107
|
+
return traceEvent;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private nextTraceSeq(runDir: string): number {
|
|
111
|
+
const next = (this.traceSeqByRun.get(runDir) ?? 0) + 1;
|
|
112
|
+
this.traceSeqByRun.set(runDir, next);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async appendJsonLine(filePath: string, value: unknown): Promise<void> {
|
|
117
|
+
const prior = this.appendChainByPath.get(filePath) ?? Promise.resolve();
|
|
118
|
+
const nextWrite = prior.then(async () => {
|
|
119
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
120
|
+
await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, "utf8");
|
|
121
|
+
});
|
|
122
|
+
const tracked = nextWrite.finally(() => {
|
|
123
|
+
if (this.appendChainByPath.get(filePath) === tracked) {
|
|
124
|
+
this.appendChainByPath.delete(filePath);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
this.appendChainByPath.set(filePath, tracked);
|
|
128
|
+
await tracked;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type LoadedRunBundle = {
|
|
133
|
+
runDir: string;
|
|
134
|
+
manifest: WorkflowRunManifest;
|
|
135
|
+
state: WorkflowRunState;
|
|
136
|
+
snapshot: WorkflowDefinitionSnapshot | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Read a run bundle from disk. Returns null when the bundle is unreadable. */
|
|
140
|
+
export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
|
|
141
|
+
const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
|
|
142
|
+
const state = await readJsonFile<WorkflowRunState>(path.join(runDir, STATE_PATH));
|
|
143
|
+
if (!manifest || !state || manifest.schema !== RUN_BUNDLE_SCHEMA) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const snapshot = await readJsonFile<WorkflowDefinitionSnapshot>(
|
|
147
|
+
path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
|
|
148
|
+
);
|
|
149
|
+
return { runDir, manifest, state, snapshot };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** List run bundles under `outputRoot`, most recently started first. */
|
|
153
|
+
export async function listRunBundles(outputRoot: string): Promise<LoadedRunBundle[]> {
|
|
154
|
+
let entries: string[];
|
|
155
|
+
try {
|
|
156
|
+
entries = await fs.readdir(outputRoot);
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const bundles: LoadedRunBundle[] = [];
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const bundle = await readRunBundle(path.join(outputRoot, entry));
|
|
163
|
+
if (bundle) {
|
|
164
|
+
bundles.push(bundle);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
bundles.sort((a, b) => b.state.startedAt.localeCompare(a.state.startedAt));
|
|
168
|
+
return bundles;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
172
|
+
try {
|
|
173
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
174
|
+
return JSON.parse(raw) as T;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createManifest(state: WorkflowRunState): WorkflowRunManifest {
|
|
181
|
+
return {
|
|
182
|
+
schema: RUN_BUNDLE_SCHEMA,
|
|
183
|
+
runId: state.runId,
|
|
184
|
+
workflowName: state.workflowName,
|
|
185
|
+
...(state.runTitle !== undefined ? { runTitle: state.runTitle } : {}),
|
|
186
|
+
...(state.workflowPath !== undefined ? { workflowPath: state.workflowPath } : {}),
|
|
187
|
+
startedAt: state.startedAt,
|
|
188
|
+
...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
|
|
189
|
+
status: state.status,
|
|
190
|
+
traceSchema: TRACE_EVENT_SCHEMA,
|
|
191
|
+
paths: {
|
|
192
|
+
workflow: WORKFLOW_SNAPSHOT_PATH,
|
|
193
|
+
state: STATE_PATH,
|
|
194
|
+
trace: TRACE_PATH,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createDefinitionSnapshot(workflow: WorkflowDefinition): WorkflowDefinitionSnapshot {
|
|
200
|
+
return {
|
|
201
|
+
schema: DEFINITION_SNAPSHOT_SCHEMA,
|
|
202
|
+
name: workflow.name,
|
|
203
|
+
startAt: workflow.startAt,
|
|
204
|
+
nodes: Object.fromEntries(
|
|
205
|
+
Object.entries(workflow.nodes).map(([nodeId, node]) => [nodeId, snapshotNode(node)]),
|
|
206
|
+
),
|
|
207
|
+
edges: structuredClone(workflow.edges),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function snapshotNode(node: WorkflowNodeDefinition): WorkflowNodeSnapshot {
|
|
212
|
+
const common: WorkflowNodeSnapshot = {
|
|
213
|
+
nodeType: node.nodeType,
|
|
214
|
+
...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
|
|
215
|
+
...(node.statusDetail !== undefined ? { statusDetail: node.statusDetail } : {}),
|
|
216
|
+
};
|
|
217
|
+
if (node.nodeType === "agent" && node.expectedOutput !== undefined) {
|
|
218
|
+
common.expectedOutput = node.expectedOutput;
|
|
219
|
+
}
|
|
220
|
+
if (node.nodeType === "checkpoint" && node.summary !== undefined) {
|
|
221
|
+
common.summary = node.summary;
|
|
222
|
+
}
|
|
223
|
+
if (node.nodeType === "action") {
|
|
224
|
+
common.actionExecution = "exec" in node ? "shell" : "function";
|
|
225
|
+
}
|
|
226
|
+
return common;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
|
230
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
231
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
232
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
233
|
+
await fs.rename(tempPath, filePath);
|
|
234
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Remove ANSI escape sequences (CSI style) from a string. */
|
|
2
|
+
export function stripAnsi(text: string): string {
|
|
3
|
+
let result = "";
|
|
4
|
+
let index = 0;
|
|
5
|
+
while (index < text.length) {
|
|
6
|
+
if (text[index] === "\u001b" && text[index + 1] === "[") {
|
|
7
|
+
index += 2;
|
|
8
|
+
while (index < text.length && !/[A-Za-z]/.test(text[index] as string)) {
|
|
9
|
+
index += 1;
|
|
10
|
+
}
|
|
11
|
+
index += 1;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
result += text[index];
|
|
15
|
+
index += 1;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Remove ANSI escapes and control characters from untrusted text (model
|
|
22
|
+
* outputs, errors, titles) so rendering it cannot alter terminal state. Line
|
|
23
|
+
* breaks and tabs collapse to single spaces because callers interpolate the
|
|
24
|
+
* result into single terminal lines, where a stray newline would break
|
|
25
|
+
* viewport math and allow fake rows.
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeText(text: string): string {
|
|
28
|
+
return (
|
|
29
|
+
stripAnsi(text)
|
|
30
|
+
.replaceAll(/[\t\n\r]+/g, " ")
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
.replaceAll(/[\u0000-\u001f\u007f]/g, "")
|
|
33
|
+
);
|
|
34
|
+
}
|