@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,27 @@
|
|
|
1
|
+
export class TimeoutError extends Error {
|
|
2
|
+
readonly timeoutMs: number;
|
|
3
|
+
|
|
4
|
+
constructor(timeoutMs: number) {
|
|
5
|
+
super(`Timed out after ${timeoutMs}ms`);
|
|
6
|
+
this.name = "TimeoutError";
|
|
7
|
+
this.timeoutMs = timeoutMs;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CancelledError extends Error {
|
|
12
|
+
constructor(message = "Workflow run was cancelled") {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CancelledError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isAbortLikeError(error: unknown): boolean {
|
|
19
|
+
return error instanceof CancelledError || (error instanceof Error && error.name === "AbortError");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function errorMessage(error: unknown): string {
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
return error.message;
|
|
25
|
+
}
|
|
26
|
+
return String(error);
|
|
27
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { assertValidWorkflowDefinitionShape } from "./schema.js";
|
|
2
|
+
import type { WorkflowDefinition, WorkflowEdge, WorkflowNodeResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate the full graph: shape, known start node, known edge targets, and
|
|
6
|
+
* at most one outgoing edge per node.
|
|
7
|
+
*/
|
|
8
|
+
export function validateWorkflowDefinition(workflow: WorkflowDefinition): void {
|
|
9
|
+
assertValidWorkflowDefinitionShape(workflow);
|
|
10
|
+
if (!Object.hasOwn(workflow.nodes, workflow.startAt)) {
|
|
11
|
+
throw new Error(`Workflow start node is missing: ${workflow.startAt}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const outgoingEdges = new Set<string>();
|
|
15
|
+
for (const edge of workflow.edges) {
|
|
16
|
+
validateWorkflowEdge(workflow, edge, outgoingEdges);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
assertAllNodesReachable(workflow);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Reject nodes that no path from `startAt` can ever reach. */
|
|
23
|
+
function assertAllNodesReachable(workflow: WorkflowDefinition): void {
|
|
24
|
+
const reachable = new Set<string>([workflow.startAt]);
|
|
25
|
+
const queue = [workflow.startAt];
|
|
26
|
+
while (queue.length > 0) {
|
|
27
|
+
const nodeId = queue.shift() as string;
|
|
28
|
+
for (const edge of workflow.edges) {
|
|
29
|
+
if (edge.from !== nodeId) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const targets = "to" in edge ? [edge.to] : Object.values(edge.switch.cases);
|
|
33
|
+
for (const target of targets) {
|
|
34
|
+
if (!reachable.has(target)) {
|
|
35
|
+
reachable.add(target);
|
|
36
|
+
queue.push(target);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const unreachable = Object.keys(workflow.nodes).filter((nodeId) => !reachable.has(nodeId));
|
|
42
|
+
if (unreachable.length > 0) {
|
|
43
|
+
throw new Error(`Workflow has unreachable nodes: ${unreachable.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertKnownNode(workflow: WorkflowDefinition, nodeId: string, description: string): void {
|
|
48
|
+
// Own-property check so ids like "toString" cannot resolve through the
|
|
49
|
+
// Object prototype and later dispatch an inherited function as a node.
|
|
50
|
+
if (!Object.hasOwn(workflow.nodes, nodeId)) {
|
|
51
|
+
throw new Error(`${description}: ${nodeId}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateWorkflowEdge(
|
|
56
|
+
workflow: WorkflowDefinition,
|
|
57
|
+
edge: WorkflowEdge,
|
|
58
|
+
outgoingEdges: Set<string>,
|
|
59
|
+
): void {
|
|
60
|
+
assertKnownNode(workflow, edge.from, "Workflow edge references unknown from-node");
|
|
61
|
+
if (workflow.nodes[edge.from]?.nodeType === "checkpoint") {
|
|
62
|
+
// A checkpoint terminates the run as `waiting` and nothing resumes it,
|
|
63
|
+
// so an outgoing edge would make its targets silently unreachable.
|
|
64
|
+
throw new Error(`Workflow checkpoint node must not declare an outgoing edge: ${edge.from}`);
|
|
65
|
+
}
|
|
66
|
+
if (outgoingEdges.has(edge.from)) {
|
|
67
|
+
throw new Error(`Workflow node must not declare multiple outgoing edges: ${edge.from}`);
|
|
68
|
+
}
|
|
69
|
+
outgoingEdges.add(edge.from);
|
|
70
|
+
|
|
71
|
+
if ("to" in edge) {
|
|
72
|
+
assertKnownNode(workflow, edge.to, "Workflow edge references unknown to-node");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const target of Object.values(edge.switch.cases)) {
|
|
77
|
+
assertKnownNode(workflow, target, "Workflow switch references unknown to-node");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the next node after `from` completed with `output`. Switch edges
|
|
83
|
+
* route on a JSON path into the output (`$.field` or `$output.field`) or the
|
|
84
|
+
* node result (`$result.outcome`).
|
|
85
|
+
*/
|
|
86
|
+
export function resolveNext(
|
|
87
|
+
edges: WorkflowEdge[],
|
|
88
|
+
from: string,
|
|
89
|
+
output: unknown,
|
|
90
|
+
result?: WorkflowNodeResult,
|
|
91
|
+
): string | null {
|
|
92
|
+
const edge = edges.find((candidate) => candidate.from === from);
|
|
93
|
+
if (!edge) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if ("to" in edge) {
|
|
97
|
+
return edge.to;
|
|
98
|
+
}
|
|
99
|
+
return resolveSwitchTarget(edge, output, result);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve routing for a failed node. Only `$result.` switch edges apply, so a
|
|
104
|
+
* workflow can explicitly route on outcomes like `failed` or `timed_out`.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveNextForOutcome(
|
|
107
|
+
edges: WorkflowEdge[],
|
|
108
|
+
from: string,
|
|
109
|
+
result: WorkflowNodeResult,
|
|
110
|
+
): string | null {
|
|
111
|
+
const edge = edges.find((candidate) => candidate.from === from);
|
|
112
|
+
if (!edge || "to" in edge || !edge.switch.on.startsWith("$result.")) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return resolveSwitchTarget(edge, undefined, result);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveSwitchTarget(
|
|
119
|
+
edge: Extract<WorkflowEdge, { switch: unknown }>,
|
|
120
|
+
output: unknown,
|
|
121
|
+
result: WorkflowNodeResult | undefined,
|
|
122
|
+
): string {
|
|
123
|
+
const value = getBySwitchPath(output, result, edge.switch.on);
|
|
124
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
125
|
+
throw new Error(`Workflow switch value must be scalar for ${edge.switch.on}`);
|
|
126
|
+
}
|
|
127
|
+
const next = edge.switch.cases[String(value)];
|
|
128
|
+
if (!next) {
|
|
129
|
+
throw new Error(`No workflow switch case for ${edge.switch.on}=${JSON.stringify(value)}`);
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getBySwitchPath(
|
|
135
|
+
output: unknown,
|
|
136
|
+
result: WorkflowNodeResult | undefined,
|
|
137
|
+
jsonPath: string,
|
|
138
|
+
): unknown {
|
|
139
|
+
if (jsonPath.startsWith("$result.")) {
|
|
140
|
+
return getByPath(result, `$.${jsonPath.slice("$result.".length)}`);
|
|
141
|
+
}
|
|
142
|
+
if (jsonPath.startsWith("$output.")) {
|
|
143
|
+
return getByPath(output, `$.${jsonPath.slice("$output.".length)}`);
|
|
144
|
+
}
|
|
145
|
+
return getByPath(output, jsonPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getByPath(value: unknown, jsonPath: string): unknown {
|
|
149
|
+
if (!jsonPath.startsWith("$.")) {
|
|
150
|
+
throw new Error(`Unsupported JSON path: ${jsonPath}`);
|
|
151
|
+
}
|
|
152
|
+
return jsonPath
|
|
153
|
+
.slice(2)
|
|
154
|
+
.split(".")
|
|
155
|
+
.reduce((current: unknown, key) => {
|
|
156
|
+
if (current == null || typeof current !== "object") {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return (current as Record<string, unknown>)[key];
|
|
160
|
+
}, value);
|
|
161
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export {
|
|
2
|
+
agent,
|
|
3
|
+
action,
|
|
4
|
+
checkpoint,
|
|
5
|
+
compute,
|
|
6
|
+
defineWorkflow,
|
|
7
|
+
isWorkflowDefinition,
|
|
8
|
+
shell,
|
|
9
|
+
} from "./definition.js";
|
|
10
|
+
export { decision, decisionEdge, type DecisionDefinition } from "./decision.js";
|
|
11
|
+
export { WorkflowEngine, appendStepContract } from "./engine.js";
|
|
12
|
+
export { CancelledError, TimeoutError } from "./errors.js";
|
|
13
|
+
export {
|
|
14
|
+
extractJsonValue,
|
|
15
|
+
parseJsonValue,
|
|
16
|
+
parseStrictJsonValue,
|
|
17
|
+
type JsonParseMode,
|
|
18
|
+
} from "./json.js";
|
|
19
|
+
export { resolveNext, resolveNextForOutcome, validateWorkflowDefinition } from "./graph.js";
|
|
20
|
+
export {
|
|
21
|
+
discoverWorkflows,
|
|
22
|
+
loadWorkflowFile,
|
|
23
|
+
resolveWorkflowRef,
|
|
24
|
+
workflowFileStem,
|
|
25
|
+
workflowSearchDirs,
|
|
26
|
+
type DiscoveredWorkflow,
|
|
27
|
+
type WorkflowSearchPaths,
|
|
28
|
+
} from "./loader.js";
|
|
29
|
+
export { renderShellCommand, runShellAction } from "./shell.js";
|
|
30
|
+
export { sanitizeText, stripAnsi } from "./text.js";
|
|
31
|
+
export {
|
|
32
|
+
DEFINITION_SNAPSHOT_SCHEMA,
|
|
33
|
+
RUN_BUNDLE_SCHEMA,
|
|
34
|
+
TRACE_EVENT_SCHEMA,
|
|
35
|
+
WorkflowRunStore,
|
|
36
|
+
createDefinitionSnapshot,
|
|
37
|
+
createRunId,
|
|
38
|
+
listRunBundles,
|
|
39
|
+
readRunBundle,
|
|
40
|
+
workflowRunsBaseDir,
|
|
41
|
+
type LoadedRunBundle,
|
|
42
|
+
} from "./store.js";
|
|
43
|
+
export type {
|
|
44
|
+
AgentNodeDefinition,
|
|
45
|
+
AgentStepContract,
|
|
46
|
+
AgentStepExecutor,
|
|
47
|
+
AgentStepRequest,
|
|
48
|
+
AgentStepSubmission,
|
|
49
|
+
ActionNodeDefinition,
|
|
50
|
+
CheckpointNodeDefinition,
|
|
51
|
+
ComputeNodeDefinition,
|
|
52
|
+
FunctionActionNodeDefinition,
|
|
53
|
+
MaybePromise,
|
|
54
|
+
ShellActionExecution,
|
|
55
|
+
ShellActionNodeDefinition,
|
|
56
|
+
ShellActionResult,
|
|
57
|
+
WorkflowActionReceipt,
|
|
58
|
+
WorkflowDefinition,
|
|
59
|
+
WorkflowDefinitionSnapshot,
|
|
60
|
+
WorkflowEdge,
|
|
61
|
+
WorkflowEngineOptions,
|
|
62
|
+
WorkflowNodeCommon,
|
|
63
|
+
WorkflowNodeContext,
|
|
64
|
+
WorkflowNodeDefinition,
|
|
65
|
+
WorkflowNodeOutcome,
|
|
66
|
+
WorkflowNodeResult,
|
|
67
|
+
WorkflowNodeSnapshot,
|
|
68
|
+
WorkflowPresentationContext,
|
|
69
|
+
WorkflowRunManifest,
|
|
70
|
+
WorkflowRunResult,
|
|
71
|
+
WorkflowRunState,
|
|
72
|
+
WorkflowRunStatus,
|
|
73
|
+
WorkflowStepRecord,
|
|
74
|
+
WorkflowTraceEvent,
|
|
75
|
+
WorkflowTraceEventDraft,
|
|
76
|
+
} from "./types.js";
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export type JsonParseMode = "strict" | "fenced" | "compat";
|
|
2
|
+
|
|
3
|
+
type ParseAttempt = { ok: true; value: unknown } | { ok: false };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a JSON value out of model text. `strict` requires the whole text to
|
|
7
|
+
* be JSON. `fenced` additionally accepts a ```json fenced block. `compat`
|
|
8
|
+
* (the default) also scans for the first balanced JSON object or array
|
|
9
|
+
* embedded in chatty output.
|
|
10
|
+
*/
|
|
11
|
+
export function parseJsonValue(text: string, options: { mode?: JsonParseMode } = {}): unknown {
|
|
12
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
throw new Error("Expected JSON output, got empty text");
|
|
15
|
+
}
|
|
16
|
+
const mode = options.mode ?? "compat";
|
|
17
|
+
|
|
18
|
+
const direct = tryParse(trimmed);
|
|
19
|
+
if (direct.ok) {
|
|
20
|
+
return direct.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (mode === "fenced" || mode === "compat") {
|
|
24
|
+
const fencedText = extractFencedJsonText(trimmed);
|
|
25
|
+
if (fencedText !== null) {
|
|
26
|
+
const fenced = tryParse(fencedText);
|
|
27
|
+
if (fenced.ok) {
|
|
28
|
+
return fenced.value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (mode === "compat") {
|
|
34
|
+
for (const candidate of extractBalancedJsonCandidates(trimmed)) {
|
|
35
|
+
const parsed = tryParse(candidate);
|
|
36
|
+
if (parsed.ok) {
|
|
37
|
+
return parsed.value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`Could not parse JSON from text:\n${trimmed}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Strict parse: the whole text must be a JSON value. */
|
|
46
|
+
export function parseStrictJsonValue(text: string): unknown {
|
|
47
|
+
return parseJsonValue(text, { mode: "strict" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Default tolerant parser: direct JSON, then fenced, then embedded object. */
|
|
51
|
+
export function extractJsonValue(text: string): unknown {
|
|
52
|
+
return parseJsonValue(text, { mode: "compat" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tryParse(text: string): ParseAttempt {
|
|
56
|
+
try {
|
|
57
|
+
return { ok: true, value: JSON.parse(text) };
|
|
58
|
+
} catch {
|
|
59
|
+
return { ok: false };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function extractFencedJsonText(text: string): string | null {
|
|
64
|
+
const openingFenceIndex = text.indexOf("```");
|
|
65
|
+
if (openingFenceIndex === -1) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let contentStart = openingFenceIndex + 3;
|
|
70
|
+
if (
|
|
71
|
+
text.slice(contentStart, contentStart + 4).toLowerCase() === "json" &&
|
|
72
|
+
isFenceWhitespace(text[contentStart + 4])
|
|
73
|
+
) {
|
|
74
|
+
contentStart += 4;
|
|
75
|
+
}
|
|
76
|
+
while (isFenceWhitespace(text[contentStart])) {
|
|
77
|
+
contentStart += 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const closingFenceIndex = text.indexOf("```", contentStart);
|
|
81
|
+
if (closingFenceIndex === -1) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return text.slice(contentStart, closingFenceIndex).trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isFenceWhitespace(char: string | undefined): boolean {
|
|
88
|
+
return char === " " || char === "\n" || char === "\r" || char === "\t";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Bound on balanced-scan attempts. Without it, pathological text such as
|
|
92
|
+
// twenty thousand opening braces makes candidate extraction quadratic and
|
|
93
|
+
// blocks the event loop, which also prevents node timeouts from firing.
|
|
94
|
+
const MAX_CANDIDATE_SCANS = 64;
|
|
95
|
+
|
|
96
|
+
function extractBalancedJsonCandidates(text: string): string[] {
|
|
97
|
+
const candidates: string[] = [];
|
|
98
|
+
let scans = 0;
|
|
99
|
+
for (let index = 0; index < text.length && scans < MAX_CANDIDATE_SCANS; index += 1) {
|
|
100
|
+
if (text[index] !== "{" && text[index] !== "[") {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
scans += 1;
|
|
104
|
+
const candidate = scanBalanced(text, index);
|
|
105
|
+
if (candidate !== null) {
|
|
106
|
+
candidates.push(candidate);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return candidates;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function scanBalanced(text: string, startIndex: number): string | null {
|
|
113
|
+
const stack: string[] = [];
|
|
114
|
+
let inString = false;
|
|
115
|
+
let escaped = false;
|
|
116
|
+
|
|
117
|
+
for (let index = startIndex; index < text.length; index += 1) {
|
|
118
|
+
const char = text[index] as string;
|
|
119
|
+
|
|
120
|
+
if (inString) {
|
|
121
|
+
if (escaped) {
|
|
122
|
+
escaped = false;
|
|
123
|
+
} else if (char === "\\") {
|
|
124
|
+
escaped = true;
|
|
125
|
+
} else if (char === '"') {
|
|
126
|
+
inString = false;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (char === '"') {
|
|
132
|
+
inString = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (char === "{" || char === "[") {
|
|
136
|
+
stack.push(char);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char !== "}" && char !== "]") {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const open = stack.at(-1);
|
|
144
|
+
const matches = (open === "{" && char === "}") || (open === "[" && char === "]");
|
|
145
|
+
if (!matches) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
stack.pop();
|
|
149
|
+
if (stack.length === 0) {
|
|
150
|
+
return text.slice(startIndex, index + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
import { isWorkflowDefinition } from "./definition.js";
|
|
7
|
+
import type { WorkflowDefinition } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts", ".workflow.mjs"];
|
|
10
|
+
|
|
11
|
+
export type DiscoveredWorkflow = {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
source: "project" | "global" | "path";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type WorkflowSearchPaths = {
|
|
18
|
+
cwd: string;
|
|
19
|
+
homeDir?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Directories scanned for `*.workflow.ts` files, in precedence order. */
|
|
23
|
+
export function workflowSearchDirs(
|
|
24
|
+
options: WorkflowSearchPaths,
|
|
25
|
+
): { dir: string; source: "project" | "global" }[] {
|
|
26
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
27
|
+
return [
|
|
28
|
+
{ dir: path.join(options.cwd, ".pi", "workflows"), source: "project" },
|
|
29
|
+
{ dir: path.join(homeDir, ".pi", "agent", "workflows"), source: "global" },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isWorkflowFile(fileName: string): boolean {
|
|
34
|
+
return WORKFLOW_FILE_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function workflowFileStem(filePath: string): string {
|
|
38
|
+
const base = path.basename(filePath);
|
|
39
|
+
const suffix = WORKFLOW_FILE_SUFFIXES.find((candidate) => base.endsWith(candidate));
|
|
40
|
+
return suffix ? base.slice(0, -suffix.length) : base;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Alias the package name to this module's own entry so workflow files can
|
|
44
|
+
// `import { agent } from "@osolmaz/pi-workflows"` whether the engine runs from src
|
|
45
|
+
// (tests, tsx) or from the built dist inside the installed package.
|
|
46
|
+
const SELF_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
|
|
47
|
+
|
|
48
|
+
/** Load a workflow module from disk. The default export must be `defineWorkflow(...)`. */
|
|
49
|
+
export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefinition> {
|
|
50
|
+
const absolutePath = path.resolve(filePath);
|
|
51
|
+
const jiti = createJiti(pathToFileURL(absolutePath).href, {
|
|
52
|
+
interopDefault: true,
|
|
53
|
+
moduleCache: false,
|
|
54
|
+
alias: { "@osolmaz/pi-workflows": SELF_ENTRY },
|
|
55
|
+
});
|
|
56
|
+
const loaded = (await jiti.import(absolutePath, { default: true })) as unknown;
|
|
57
|
+
if (!isWorkflowDefinition(loaded)) {
|
|
58
|
+
throw new Error(`Workflow module must default-export defineWorkflow(...): ${absolutePath}`);
|
|
59
|
+
}
|
|
60
|
+
return loaded;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Discover named workflows in the project and global workflow directories. */
|
|
64
|
+
export async function discoverWorkflows(
|
|
65
|
+
options: WorkflowSearchPaths,
|
|
66
|
+
): Promise<DiscoveredWorkflow[]> {
|
|
67
|
+
const discovered: DiscoveredWorkflow[] = [];
|
|
68
|
+
const seenNames = new Set<string>();
|
|
69
|
+
for (const { dir, source } of workflowSearchDirs(options)) {
|
|
70
|
+
for (const filePath of await listWorkflowFiles(dir)) {
|
|
71
|
+
const name = workflowFileStem(filePath);
|
|
72
|
+
if (seenNames.has(name)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
seenNames.add(name);
|
|
76
|
+
discovered.push({ name, path: filePath, source });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return discovered;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function listWorkflowFiles(dir: string): Promise<string[]> {
|
|
83
|
+
let entries: import("node:fs").Dirent[];
|
|
84
|
+
try {
|
|
85
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return entries
|
|
90
|
+
.filter((entry) => entry.isFile() && isWorkflowFile(entry.name))
|
|
91
|
+
.map((entry) => path.join(dir, entry.name))
|
|
92
|
+
.sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a `/workflow` argument to a workflow file. Accepts a discovered
|
|
97
|
+
* workflow name or a direct path to a `*.workflow.ts` file.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveWorkflowRef(
|
|
100
|
+
ref: string,
|
|
101
|
+
options: WorkflowSearchPaths,
|
|
102
|
+
): Promise<{ path: string; source: DiscoveredWorkflow["source"] }> {
|
|
103
|
+
if (looksLikePath(ref)) {
|
|
104
|
+
const absolutePath = path.resolve(options.cwd, ref);
|
|
105
|
+
await fs.access(absolutePath);
|
|
106
|
+
return { path: absolutePath, source: "path" };
|
|
107
|
+
}
|
|
108
|
+
const discovered = await discoverWorkflows(options);
|
|
109
|
+
const match = discovered.find((workflow) => workflow.name === ref);
|
|
110
|
+
if (!match) {
|
|
111
|
+
const available = discovered.map((workflow) => workflow.name).join(", ") || "(none)";
|
|
112
|
+
throw new Error(`Unknown workflow ${JSON.stringify(ref)}. Available workflows: ${available}`);
|
|
113
|
+
}
|
|
114
|
+
return { path: match.path, source: match.source };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function looksLikePath(ref: string): boolean {
|
|
118
|
+
return (
|
|
119
|
+
ref.includes("/") ||
|
|
120
|
+
ref.includes("\\") ||
|
|
121
|
+
WORKFLOW_FILE_SUFFIXES.some((suffix) => ref.endsWith(suffix))
|
|
122
|
+
);
|
|
123
|
+
}
|