pi-workflow-engine 1.1.0 → 1.3.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/.pi/extensions/pi-workflow-engine/index.ts +199 -85
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +11 -3
- package/USAGE.md +124 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +162 -0
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
isPersistedWorkflowBackground,
|
|
18
18
|
type PersistedWorkflowBackground,
|
|
19
19
|
} from "./workflow-run-background.ts";
|
|
20
|
+
import { isWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
|
|
20
21
|
|
|
21
22
|
export const WORKFLOW_RUN_RECORD_VERSION = 1;
|
|
23
|
+
export const WORKFLOW_RUN_SCRIPT_SUFFIX = ".script.js";
|
|
22
24
|
|
|
23
25
|
const MAX_RESULT_BYTES = 1 << 20;
|
|
24
26
|
const MAX_RESULT_DEPTH = 64;
|
|
@@ -97,6 +99,14 @@ interface WorkflowRunRecordBase {
|
|
|
97
99
|
readonly runId: string;
|
|
98
100
|
readonly workflow: PersistedWorkflowIdentity;
|
|
99
101
|
readonly journalFile: string;
|
|
102
|
+
/** An inline run's script, persisted beside the record; prompts in it never enter the record itself. */
|
|
103
|
+
readonly scriptFile?: string;
|
|
104
|
+
/**
|
|
105
|
+
* This install's HMAC over the run id and source fingerprint, proving the engine recorded the script
|
|
106
|
+
* rather than a repository shipping it. A run of an inline or saved script relaunches from
|
|
107
|
+
* `/workflow:runs` only with one.
|
|
108
|
+
*/
|
|
109
|
+
readonly scriptSeal?: string;
|
|
100
110
|
readonly options: PersistedWorkflowRunOptions;
|
|
101
111
|
readonly createdAt: number;
|
|
102
112
|
readonly updatedAt: number;
|
|
@@ -125,6 +135,10 @@ export function createWorkflowRunRecord(input: {
|
|
|
125
135
|
readonly options: ResolvedWorkflowRunOptions;
|
|
126
136
|
readonly progress: WorkflowProgressSnapshot;
|
|
127
137
|
readonly argumentsPresent?: boolean;
|
|
138
|
+
/** Whether the run's inline script was persisted beside the record. */
|
|
139
|
+
readonly scriptPersisted?: boolean;
|
|
140
|
+
/** The run's script seal; see {@link WorkflowRunRecordBase.scriptSeal}. */
|
|
141
|
+
readonly scriptSeal?: string;
|
|
128
142
|
}): WorkflowRunRecord {
|
|
129
143
|
const runId = validateWorkflowRunId(input.runId);
|
|
130
144
|
assertMatchingRun(runId, input.progress.runId);
|
|
@@ -134,6 +148,8 @@ export function createWorkflowRunRecord(input: {
|
|
|
134
148
|
state: "queued",
|
|
135
149
|
workflow: persistedWorkflowIdentity(input.workflow),
|
|
136
150
|
journalFile: `${runId}.jsonl`,
|
|
151
|
+
scriptFile: input.scriptPersisted ? workflowRunScriptFile(runId) : undefined,
|
|
152
|
+
scriptSeal: input.scriptSeal,
|
|
137
153
|
options: persistedWorkflowRunOptions(input.options, input.argumentsPresent ?? false),
|
|
138
154
|
createdAt: input.progress.startedAt,
|
|
139
155
|
updatedAt: input.progress.startedAt,
|
|
@@ -142,6 +158,11 @@ export function createWorkflowRunRecord(input: {
|
|
|
142
158
|
};
|
|
143
159
|
}
|
|
144
160
|
|
|
161
|
+
/** The persisted script's file name in the runs directory. */
|
|
162
|
+
export function workflowRunScriptFile(runId: string): string {
|
|
163
|
+
return `${runId}${WORKFLOW_RUN_SCRIPT_SUFFIX}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
145
166
|
export function updateWorkflowRunProgress(
|
|
146
167
|
record: WorkflowRunRecord,
|
|
147
168
|
progress: WorkflowProgressSnapshot,
|
|
@@ -169,6 +190,8 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
|
|
|
169
190
|
runId: record.runId,
|
|
170
191
|
workflow: record.workflow,
|
|
171
192
|
journalFile: record.journalFile,
|
|
193
|
+
scriptFile: record.scriptFile,
|
|
194
|
+
scriptSeal: record.scriptSeal,
|
|
172
195
|
options: record.options,
|
|
173
196
|
createdAt: record.createdAt,
|
|
174
197
|
updatedAt: at,
|
|
@@ -475,6 +498,9 @@ export function isWorkflowRunRecord(value: unknown): value is WorkflowRunRecord
|
|
|
475
498
|
if (!isValidRunId(value.runId) || !isWorkflowRunState(value.state)) return false;
|
|
476
499
|
if (!isPersistedWorkflowIdentity(value.workflow) || !isPersistedWorkflowRunOptions(value.options)) return false;
|
|
477
500
|
if (value.journalFile !== `${value.runId}.jsonl`) return false;
|
|
501
|
+
if (value.scriptFile !== undefined && value.scriptFile !== workflowRunScriptFile(value.runId)) return false;
|
|
502
|
+
// A seal covers an inline run's persisted script or a saved script, which resolves again by name.
|
|
503
|
+
if (value.scriptSeal !== undefined && (!isWorkflowRunScriptSeal(value.scriptSeal) || (value.scriptFile === undefined && value.workflow.sourceKind !== "file"))) return false;
|
|
478
504
|
if (!isFiniteNumber(value.createdAt) || !isFiniteNumber(value.updatedAt)) return false;
|
|
479
505
|
if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
|
|
480
506
|
if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return false;
|
|
@@ -568,7 +594,7 @@ function isPhaseSnapshot(value: unknown): boolean {
|
|
|
568
594
|
|
|
569
595
|
function isAgentSnapshot(value: unknown): boolean {
|
|
570
596
|
if (!isRecord(value) || !isFiniteNumber(value.id) || typeof value.label !== "string") return false;
|
|
571
|
-
if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed") return false;
|
|
597
|
+
if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed" && value.status !== "skipped") return false;
|
|
572
598
|
if (!isFiniteNumber(value.toolUses)) return false;
|
|
573
599
|
if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
|
|
574
600
|
if (value.doneAt !== undefined && !isFiniteNumber(value.doneAt)) return false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isExistingPathError, isMissingPathError } from "./filesystem-error.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The per-install key that seals inline run scripts, in pi's agent directory. It lives outside every
|
|
9
|
+
* project, so a repository can ship run records and scripts but never a seal this install accepts.
|
|
10
|
+
*/
|
|
11
|
+
export const WORKFLOW_RUN_SEAL_KEY_FILE = "workflow-run-seal.key";
|
|
12
|
+
|
|
13
|
+
const KEY_BYTES = 32;
|
|
14
|
+
const HEX_256 = /^[0-9a-f]{64}$/;
|
|
15
|
+
|
|
16
|
+
/** Whether `value` has a seal's shape; only {@link verifyWorkflowRunScriptSeal} says whether it is genuine. */
|
|
17
|
+
export function isWorkflowRunScriptSeal(value: unknown): value is string {
|
|
18
|
+
return typeof value === "string" && HEX_256.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The seal a run record carries for its persisted inline script, creating this install's key on first use. */
|
|
22
|
+
export async function sealWorkflowRunScript(
|
|
23
|
+
runId: string,
|
|
24
|
+
sourceFingerprint: string,
|
|
25
|
+
agentDir: string = getAgentDir(),
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
const path = join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE);
|
|
28
|
+
return runSeal((await readSealKey(path)) ?? (await createSealKey(agentDir, path)), runId, sourceFingerprint);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether this install sealed `sourceFingerprint` for `runId`. Never creates a key: without one, nothing was sealed here. */
|
|
32
|
+
export async function verifyWorkflowRunScriptSeal(
|
|
33
|
+
runId: string,
|
|
34
|
+
sourceFingerprint: string,
|
|
35
|
+
seal: string,
|
|
36
|
+
agentDir: string = getAgentDir(),
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
if (!isWorkflowRunScriptSeal(seal)) return false;
|
|
39
|
+
const key = await readSealKey(join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE));
|
|
40
|
+
if (key === undefined) return false;
|
|
41
|
+
return timingSafeEqual(Buffer.from(runSeal(key, runId, sourceFingerprint), "hex"), Buffer.from(seal, "hex"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runSeal(key: Buffer, runId: string, sourceFingerprint: string): string {
|
|
45
|
+
return createHmac("sha256", key).update(`${runId}\0${sourceFingerprint}`).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readSealKey(path: string): Promise<Buffer | undefined> {
|
|
49
|
+
let content: string;
|
|
50
|
+
try {
|
|
51
|
+
content = await readFile(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (isMissingPathError(error)) return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const hex = content.trim();
|
|
57
|
+
if (!HEX_256.test(hex)) {
|
|
58
|
+
throw new Error(`workflow run seal key ${path} is malformed; delete it to create a new one, which retires every existing seal`);
|
|
59
|
+
}
|
|
60
|
+
return Buffer.from(hex, "hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Publish a new key with an exclusive hard link from a complete temporary file, so a concurrent reader
|
|
65
|
+
* never sees a partial key. When another process published one first, its key wins and is read back.
|
|
66
|
+
*/
|
|
67
|
+
async function createSealKey(agentDir: string, path: string): Promise<Buffer> {
|
|
68
|
+
await mkdir(agentDir, { recursive: true });
|
|
69
|
+
const key = randomBytes(KEY_BYTES);
|
|
70
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(temporaryPath, `${key.toString("hex")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
73
|
+
await link(temporaryPath, path);
|
|
74
|
+
return key;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isExistingPathError(error)) throw error;
|
|
77
|
+
const existing = await readSealKey(path);
|
|
78
|
+
if (existing === undefined) throw error;
|
|
79
|
+
return existing;
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
|
+
import { ensureWorkflowRunsDir, validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
5
5
|
import type { WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
6
6
|
import {
|
|
7
7
|
isWorkflowRunRecord,
|
|
8
8
|
transitionWorkflowRun,
|
|
9
9
|
updateWorkflowRunProgress,
|
|
10
|
+
WORKFLOW_RUN_SCRIPT_SUFFIX,
|
|
11
|
+
workflowRunScriptFile,
|
|
10
12
|
type WorkflowRunRecord,
|
|
11
13
|
type WorkflowRunState,
|
|
12
14
|
type WorkflowRunTransition,
|
|
@@ -34,7 +36,7 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
|
|
|
34
36
|
if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
|
|
35
37
|
throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
|
|
36
38
|
}
|
|
37
|
-
await
|
|
39
|
+
await ensureWorkflowRunsDir(dirname(path));
|
|
38
40
|
try {
|
|
39
41
|
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
40
42
|
await rename(temporaryPath, path);
|
|
@@ -70,7 +72,12 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
|
|
|
70
72
|
.filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
|
|
71
73
|
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
72
74
|
.slice(Math.max(0, keep));
|
|
73
|
-
|
|
75
|
+
// A pruned record takes its persisted script with it: scripts are only ever reached through their record.
|
|
76
|
+
const paths = stale.flatMap((entry) => [
|
|
77
|
+
entry.path,
|
|
78
|
+
`${entry.path.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length)}${WORKFLOW_RUN_SCRIPT_SUFFIX}`,
|
|
79
|
+
]);
|
|
80
|
+
await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => undefined)));
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
/** Record file paths in the runs directory; empty when the directory cannot be read. */
|
|
@@ -153,6 +160,25 @@ export function workflowRunRecordPath(cwd: string, runId: string): string {
|
|
|
153
160
|
return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
export function workflowRunScriptPath(cwd: string, runId: string): string {
|
|
164
|
+
return join(cwd, WORKFLOW_RUNS_DIR, workflowRunScriptFile(validateWorkflowRunId(runId)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Persist an inline run's script beside its record, which redacts prompts and so cannot hold it. This
|
|
169
|
+
* adds no new exposure: pi's session transcript already holds the script as the tool call's arguments.
|
|
170
|
+
*/
|
|
171
|
+
export async function persistWorkflowRunScript(cwd: string, runId: string, source: string): Promise<string> {
|
|
172
|
+
const path = workflowRunScriptPath(cwd, runId);
|
|
173
|
+
await ensureWorkflowRunsDir(dirname(path));
|
|
174
|
+
await writeFile(path, source, { encoding: "utf8", mode: 0o600 });
|
|
175
|
+
return path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function readWorkflowRunScript(cwd: string, runId: string): Promise<string> {
|
|
179
|
+
return await readFile(workflowRunScriptPath(cwd, runId), "utf8");
|
|
180
|
+
}
|
|
181
|
+
|
|
156
182
|
async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
|
|
157
183
|
try {
|
|
158
184
|
const content = await readFile(path, "utf8");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { compileInlineWorkflow } from "./inline-workflow.ts";
|
|
5
|
+
import type { LoadedWorkflow } from "./types.ts";
|
|
6
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
|
|
8
|
+
/** The largest script file a `scriptPath` may name; real workflow scripts are a few kilobytes. */
|
|
9
|
+
export const MAX_WORKFLOW_SCRIPT_FILE_BYTES = 1 << 20;
|
|
10
|
+
|
|
11
|
+
/** A `scriptPath` that cannot be read. */
|
|
12
|
+
export class WorkflowScriptPathError extends Error {
|
|
13
|
+
override readonly name = "WorkflowScriptPathError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read an inline-format script named relative to `cwd`. No containment: the script runs with the
|
|
18
|
+
* extension's access either way, as an inline `script` does.
|
|
19
|
+
*/
|
|
20
|
+
export async function readWorkflowScriptFile(cwd: string, scriptPath: string): Promise<string> {
|
|
21
|
+
return await readBoundedScriptFile(resolve(cwd, scriptPath), `scriptPath ${scriptPath}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a workflow script only if it is a regular file within the size limit: a FIFO or device would
|
|
26
|
+
* block the read or never end. `label` names the file in errors.
|
|
27
|
+
*/
|
|
28
|
+
export async function readBoundedScriptFile(file: string, label: string): Promise<string> {
|
|
29
|
+
const unreadable = (error: unknown) => new WorkflowScriptPathError(`${label} could not be read: ${unknownErrorMessage(error)}`);
|
|
30
|
+
let info: Stats;
|
|
31
|
+
try {
|
|
32
|
+
info = await stat(file);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw unreadable(error);
|
|
35
|
+
}
|
|
36
|
+
if (!info.isFile()) throw new WorkflowScriptPathError(`${label} is not a regular file`);
|
|
37
|
+
if (info.size > MAX_WORKFLOW_SCRIPT_FILE_BYTES) {
|
|
38
|
+
throw new WorkflowScriptPathError(`${label} is ${info.size} bytes, over the ${MAX_WORKFLOW_SCRIPT_FILE_BYTES}-byte limit`);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return await readFile(file, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw unreadable(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compile a script file exactly like an inline `script`: same contract, injected `Type`, content fingerprint. */
|
|
48
|
+
export async function compileWorkflowScriptFile(cwd: string, scriptPath: string): Promise<LoadedWorkflow> {
|
|
49
|
+
return compileInlineWorkflow(await readWorkflowScriptFile(cwd, scriptPath));
|
|
50
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
|
|
4
|
+
import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
|
|
5
|
+
import { isRecord } from "./guards.ts";
|
|
6
|
+
import { DEFAULT_WORKFLOW_MAX_AGENTS } from "./options.ts";
|
|
7
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How large the host should make each workflow it authors. A prompt guideline only: the run's
|
|
11
|
+
* `maxAgents` limit stays the separate hard cap.
|
|
12
|
+
*/
|
|
13
|
+
export const WORKFLOW_SIZE_GUIDELINES = ["small", "medium", "large", "off"] as const;
|
|
14
|
+
export type WorkflowSizeGuideline = (typeof WORKFLOW_SIZE_GUIDELINES)[number];
|
|
15
|
+
export const DEFAULT_WORKFLOW_SIZE_GUIDELINE: WorkflowSizeGuideline = "medium";
|
|
16
|
+
|
|
17
|
+
const SIZE_AGENT_LIMITS: Readonly<Record<Exclude<WorkflowSizeGuideline, "off">, number>> = { small: 5, medium: 10, large: 25 };
|
|
18
|
+
const SETTINGS_FILE_NAME = "workflow-settings.json";
|
|
19
|
+
const SETTINGS_LABEL = "workflow settings";
|
|
20
|
+
|
|
21
|
+
export interface WorkflowSettingsFile {
|
|
22
|
+
readonly size?: WorkflowSizeGuideline;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ResolvedWorkflowSize =
|
|
26
|
+
| { readonly size: WorkflowSizeGuideline; readonly source: "default" }
|
|
27
|
+
| { readonly size: WorkflowSizeGuideline; readonly source: "user" | "project"; readonly configPath: string };
|
|
28
|
+
|
|
29
|
+
export function workflowSettingsPaths(cwd: string, agentDir?: string): ConfigFilePaths {
|
|
30
|
+
return configFilePaths(SETTINGS_FILE_NAME, cwd, agentDir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isWorkflowSizeGuideline(value: string): value is WorkflowSizeGuideline {
|
|
34
|
+
return WORKFLOW_SIZE_GUIDELINES.includes(value as WorkflowSizeGuideline);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readWorkflowSettingsFile(configPath: string): WorkflowSettingsFile {
|
|
38
|
+
const parsed = readJsonConfigFile(configPath, SETTINGS_LABEL);
|
|
39
|
+
if (parsed === undefined) return {};
|
|
40
|
+
if (!isRecord(parsed)) throw invalidSettings(configPath, "the root must be an object");
|
|
41
|
+
const unexpected = Object.keys(parsed).find((key) => key !== "size");
|
|
42
|
+
if (unexpected) throw invalidSettings(configPath, `unexpected key "${unexpected}"`);
|
|
43
|
+
const { size } = parsed;
|
|
44
|
+
if (size === undefined) return {};
|
|
45
|
+
if (typeof size !== "string" || !isWorkflowSizeGuideline(size)) {
|
|
46
|
+
throw invalidSettings(configPath, '"size" must be small, medium, large, or off');
|
|
47
|
+
}
|
|
48
|
+
return { size };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The project file wins over the user file, which wins over the default. */
|
|
52
|
+
export function resolveWorkflowSize(paths: ConfigFilePaths): ResolvedWorkflowSize {
|
|
53
|
+
for (const source of ["project", "user"] as const) {
|
|
54
|
+
const { size } = readWorkflowSettingsFile(paths[source]);
|
|
55
|
+
if (size !== undefined) return { size, source, configPath: paths[source] };
|
|
56
|
+
}
|
|
57
|
+
return { size: DEFAULT_WORKFLOW_SIZE_GUIDELINE, source: "default" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setWorkflowSize(configPath: string, size: WorkflowSizeGuideline): void {
|
|
61
|
+
const current = readWorkflowSettingsFile(configPath);
|
|
62
|
+
writeJsonConfigFile(configPath, { ...current, size }, SETTINGS_LABEL);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The catalog line for a size, or undefined when the guideline is off. */
|
|
66
|
+
export function workflowSizeGuidelineLine(size: WorkflowSizeGuideline): string | undefined {
|
|
67
|
+
if (size === "off") return undefined;
|
|
68
|
+
return `Workflow size guideline: ${size} — keep each workflow under ${SIZE_AGENT_LIMITS[size]} agents unless the user asks for more; the user changes this with /workflow:size.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type WorkflowSizeCommand =
|
|
72
|
+
| { readonly kind: "status" }
|
|
73
|
+
| { readonly kind: "set"; readonly size: WorkflowSizeGuideline; readonly scope: keyof ConfigFilePaths }
|
|
74
|
+
| { readonly kind: "error"; readonly message: string };
|
|
75
|
+
|
|
76
|
+
const USAGE = "Usage: /workflow:size [small|medium|large|off] [--user|--project]";
|
|
77
|
+
const SIZE_COMPLETIONS = WORKFLOW_SIZE_GUIDELINES.map((value) => ({
|
|
78
|
+
value,
|
|
79
|
+
description: value === "off" ? "No size guideline in the prompt" : `Keep each workflow under ${SIZE_AGENT_LIMITS[value]} agents`,
|
|
80
|
+
}));
|
|
81
|
+
const SCOPE_COMPLETIONS = [
|
|
82
|
+
{ value: "--user", description: "Write the user-level workflow settings" },
|
|
83
|
+
{ value: "--project", description: "Write the project-level workflow settings" },
|
|
84
|
+
] as const;
|
|
85
|
+
|
|
86
|
+
export function parseWorkflowSizeCommand(args: string): WorkflowSizeCommand {
|
|
87
|
+
const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
88
|
+
const userScope = tokens.includes("--user");
|
|
89
|
+
const projectScope = tokens.includes("--project");
|
|
90
|
+
if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
|
|
91
|
+
const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
|
|
92
|
+
if (positional.length === 0) {
|
|
93
|
+
return userScope || projectScope ? { kind: "error", message: "Name a size to write." } : { kind: "status" };
|
|
94
|
+
}
|
|
95
|
+
const [size, ...extra] = positional;
|
|
96
|
+
if (extra.length > 0 || !size || !isWorkflowSizeGuideline(size)) {
|
|
97
|
+
return { kind: "error", message: "Size must be small, medium, large, or off." };
|
|
98
|
+
}
|
|
99
|
+
return { kind: "set", size, scope: projectScope ? "project" : "user" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function workflowSizeArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
103
|
+
const { completed } = splitArgumentPrefix(argumentPrefix);
|
|
104
|
+
if (completed.length === 0) return completeCurrentArgument(argumentPrefix, SIZE_COMPLETIONS);
|
|
105
|
+
if (completed.length === 1 && isWorkflowSizeGuideline(completed[0] ?? "")) return completeCurrentArgument(argumentPrefix, SCOPE_COMPLETIONS);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatWorkflowSize(resolved: ResolvedWorkflowSize): string {
|
|
110
|
+
const source = resolved.source === "default" ? "default" : `${resolved.source} config ${resolved.configPath}`;
|
|
111
|
+
const rule = resolved.size === "off"
|
|
112
|
+
? "no size guideline is added to the prompt"
|
|
113
|
+
: `keep each workflow under ${SIZE_AGENT_LIMITS[resolved.size]} agents unless the user asks for more`;
|
|
114
|
+
return [
|
|
115
|
+
`Workflow size guideline: ${resolved.size} (${source}) — ${rule}.`,
|
|
116
|
+
`The maxAgents run limit (default ${DEFAULT_WORKFLOW_MAX_AGENTS}) stays the hard cap on live agents per run.`,
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Register `/workflow:size`; `onChange` lets the session's prompt pick the new guideline up. */
|
|
121
|
+
export function registerWorkflowSizeCommand(pi: Pick<ExtensionAPI, "registerCommand">, onChange: (ctx: ExtensionCommandContext) => void): void {
|
|
122
|
+
pi.registerCommand("workflow:size", {
|
|
123
|
+
description: "Show or set the workflow size guideline: /workflow:size [small|medium|large|off] [--user|--project]",
|
|
124
|
+
getArgumentCompletions: workflowSizeArgumentCompletions,
|
|
125
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
126
|
+
const command = parseWorkflowSizeCommand(args);
|
|
127
|
+
if (command.kind === "error") {
|
|
128
|
+
ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const paths = workflowSettingsPaths(ctx.cwd);
|
|
133
|
+
if (command.kind === "set") {
|
|
134
|
+
setWorkflowSize(paths[command.scope], command.size);
|
|
135
|
+
onChange(ctx);
|
|
136
|
+
}
|
|
137
|
+
ctx.ui.notify(formatWorkflowSize(resolveWorkflowSize(paths)), "info");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
ctx.ui.notify(unknownErrorMessage(error), "error");
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function invalidSettings(configPath: string, reason: string): Error {
|
|
146
|
+
return new Error(`Invalid workflow settings at ${configPath}: ${reason}. Fix or remove the file.`);
|
|
147
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface WorkflowToolRequestParams {
|
|
2
|
+
readonly name?: string;
|
|
3
|
+
readonly script?: string;
|
|
4
|
+
readonly scriptPath?: string;
|
|
5
|
+
readonly resumeFromRunId?: string;
|
|
6
|
+
readonly background?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type WorkflowToolRequest =
|
|
10
|
+
| { readonly kind: "named"; readonly name: string }
|
|
11
|
+
| { readonly kind: "inline"; readonly script: string }
|
|
12
|
+
| { readonly kind: "script-path"; readonly scriptPath: string }
|
|
13
|
+
| { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
|
|
14
|
+
|
|
15
|
+
export const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one of a workflow name, an inline workflow script, or a scriptPath.";
|
|
16
|
+
|
|
17
|
+
export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
|
|
18
|
+
const name = params.name?.trim() ?? "";
|
|
19
|
+
const script = params.script?.trim() ?? "";
|
|
20
|
+
const scriptPath = params.scriptPath?.trim() ?? "";
|
|
21
|
+
const provided = [name, script, scriptPath].filter((value) => value.length > 0).length;
|
|
22
|
+
if (provided !== 1) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
|
|
23
|
+
if (name) return { kind: "named", name };
|
|
24
|
+
return script ? { kind: "inline", script } : { kind: "script-path", scriptPath };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** pi names MCP tools `mcp__<server>__<tool>`; such a word contains its server's `mcp__<server>` namespace. */
|
|
28
|
+
const MCP_TOOL_REFERENCE = /mcp__[A-Za-z0-9_-]+/g;
|
|
29
|
+
/** `api.workflow(...)` or a destructured `workflow(...)`; prose that happens to match only costs one wait. */
|
|
30
|
+
const SUB_WORKFLOW_CALL = /\bworkflow\s*\(/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which tools a `workflow` tool call may reach, for pi's `reachesTools`, read from the same request the
|
|
34
|
+
* call executes. Before the call runs, pi's MCP extension waits for every server whose namespace a
|
|
35
|
+
* returned word contains, or for all of them on `"all"`. Without that wait a synchronous run early in a
|
|
36
|
+
* session can start before MCP tools register, and agents that name them then fail at the host-tools bridge.
|
|
37
|
+
*
|
|
38
|
+
* - A background run never gets the bridge, so it reaches nothing.
|
|
39
|
+
* - A named workflow is opaque, so it reaches `"all"`. Each server's readiness settles once, so this
|
|
40
|
+
* costs at most one wait at session start.
|
|
41
|
+
* - An inline script reaches the MCP tools it names, or `"all"` when it asks for `external-search`
|
|
42
|
+
* tools or composes a sub-workflow, which is as opaque as a named one.
|
|
43
|
+
* - A script file reaches `"all"` unread. pi asks synchronously on its main thread, where reading a FIFO,
|
|
44
|
+
* a terminal or a device named as `scriptPath` would block or never end.
|
|
45
|
+
* - An invalid call fails before running anything, so it reaches nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function workflowToolReach(input: Record<string, unknown>): "all" | readonly string[] {
|
|
48
|
+
if (input.background === true) return [];
|
|
49
|
+
const request = normalizeWorkflowToolRequest({ name: text(input.name), script: text(input.script), scriptPath: text(input.scriptPath) });
|
|
50
|
+
if (request.kind === "inline") return scriptReach(request.script);
|
|
51
|
+
return request.kind === "error" ? [] : "all";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scriptReach(script: string): "all" | readonly string[] {
|
|
55
|
+
if (script.includes("external-search") || SUB_WORKFLOW_CALL.test(script)) return "all";
|
|
56
|
+
return [...new Set(script.match(MCP_TOOL_REFERENCE) ?? [])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function text(value: unknown): string | undefined {
|
|
60
|
+
return typeof value === "string" ? value : undefined;
|
|
61
|
+
}
|