chore: archive legacy workflow extensions

This commit is contained in:
jb
2026-09-05 22:44:40 -04:00
parent eb52030a38
commit 45d6a41a39
71 changed files with 25 additions and 36 deletions
+7
View File
@@ -0,0 +1,7 @@
# Archived extensions
`workflow/` and `_workflows/` are retired historical implementations, archived by explicit approval under [ADR 0007](../../docs/adr/0007-archive-legacy-workflows-05SEP2026.md). New development uses the [`develop-workflow` skill](../../skills/develop/develop-workflow/SKILL.md).
Their source and tests are retained for reference, excluded from active workspaces, typechecking, and root test commands. Historical source still contains relative imports into the former `extensions/shared` location and workspace dependency declarations; the archive is not a runnable or installable package set. To investigate the runnable historical layout, inspect commit `fee0d44e2ddd7a982e94ebf99117f69bb9bae78e` in a separate checkout rather than wiring archived files into the active runtime.
Historical plans and ADRs retain their original paths as evidence. The separate scenario and real-change pilot validation TODO remains open.
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import {
boundedArtifactTranscript,
createWorkflowPersistence,
persistWorkflowJson,
} from "./artifacts.ts";
import {
emptyUsage,
type TranscriptEntry,
type WorkflowDetails,
} from "./model.ts";
function workflowDetails(): WorkflowDetails {
return {
runId: "wf_fixture",
sessionId: "session_fixture",
background: false,
status: "running",
startedAt: 1,
phases: [],
agents: [],
};
}
test("artifact transcript keeps the initial prompt, marker, and newest entries", () => {
const prompt = `initial:${"p".repeat(70)}`;
const transcript = [
{ role: "user" as const, text: prompt },
...Array.from({ length: 5 }, (_, index) => ({
role: "assistant" as const,
text: `entry-${index}:${String(index).repeat(70)}`,
})),
];
const bounded = boundedArtifactTranscript(transcript, {
maxBytes: 256,
entryMaxBytes: 80,
});
assert.equal(bounded[0]?.role, "user");
assert.equal(bounded[0]?.text, prompt);
assert.match(bounded[1]?.text ?? "", /artifact transcript truncated/);
assert.equal(bounded.at(-1)?.text, transcript.at(-1)?.text);
assert.equal(
bounded.some((entry) => entry.text.startsWith("entry-0:")),
false,
);
assert.ok(
bounded.reduce(
(total, entry) => total + Buffer.byteLength(entry.text, "utf8"),
0,
) <= 256,
);
});
test("live artifact persistence includes current agents and transcripts", () => {
const directory = mkdtempSync(join(tmpdir(), "workflow-artifacts-"));
try {
const details = workflowDetails();
details.agents.push({
index: 1,
label: "running-fixture",
state: "running",
startedAt: 2,
preview: "working",
usage: emptyUsage(),
transcript: [
{ role: "user", text: "current prompt" },
{
role: "tool",
name: "fixture",
toolCallId: "call-fixture",
text: "{}",
startedAt: 10,
finishedAt: 25,
durationMs: 15,
},
],
});
persistWorkflowJson(directory, details);
const workflow = JSON.parse(
readFileSync(join(directory, "workflow.json"), "utf8"),
) as WorkflowDetails;
const transcripts = JSON.parse(
readFileSync(join(directory, "transcripts.json"), "utf8"),
) as Record<string, TranscriptEntry[]>;
assert.equal(workflow.agents.length, 1);
assert.equal(workflow.agents[0]?.label, "running-fixture");
assert.equal(transcripts["1"]?.[0]?.text, "current prompt");
assert.deepEqual(
{
toolCallId: transcripts["1"]?.[1]?.toolCallId,
startedAt: transcripts["1"]?.[1]?.startedAt,
finishedAt: transcripts["1"]?.[1]?.finishedAt,
durationMs: transcripts["1"]?.[1]?.durationMs,
},
{
toolCallId: "call-fixture",
startedAt: 10,
finishedAt: 25,
durationMs: 15,
},
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("workflow checkpoints throttle updates and support immediate/final flushes", async () => {
const details = workflowDetails();
const snapshots: WorkflowDetails[] = [];
const persistence = createWorkflowPersistence("fixture", details, {
intervalMs: 15,
persist: (_runDir, current) => snapshots.push(structuredClone(current)),
});
details.currentPhase = "Scan";
persistence.checkpoint();
details.currentPhase = "Review";
persistence.checkpoint();
assert.equal(snapshots.length, 0);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(snapshots.length, 1);
assert.equal(snapshots[0]?.currentPhase, "Review");
details.status = "completed";
persistence.checkpoint({ immediate: true });
assert.equal(snapshots.length, 2);
assert.equal(snapshots[1]?.status, "completed");
details.finishedAt = 3;
persistence.flush();
assert.equal(snapshots.length, 3);
assert.equal(snapshots[2]?.finishedAt, 3);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(snapshots.length, 3);
});
@@ -0,0 +1,179 @@
import type { TranscriptEntry, WorkflowDetails } from "./model.ts";
import {
safeStringify,
truncateUtf8,
writeFileAtomic,
} from "./serialization.ts";
import * as path from "node:path";
const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
const TRANSCRIPT_TRUNCATION_MARKER =
"[artifact transcript truncated: older entries omitted]";
function textBytes(text: string) {
return Buffer.byteLength(text, "utf8");
}
function boundEntry(entry: TranscriptEntry, maxBytes: number) {
if (textBytes(entry.text) <= maxBytes) return { ...entry };
const markerBytes = textBytes(ENTRY_TRUNCATION_MARKER);
const text =
maxBytes > markerBytes
? `${truncateUtf8(entry.text, maxBytes - markerBytes)}${ENTRY_TRUNCATION_MARKER}`
: truncateUtf8(ENTRY_TRUNCATION_MARKER, maxBytes);
return { ...entry, text };
}
/** Keep the initial prompt plus the newest useful context within the artifact cap. */
export function boundedArtifactTranscript(
transcript: TranscriptEntry[],
options: { maxBytes?: number; entryMaxBytes?: number } = {},
) {
if (transcript.length === 0) return [];
const maxBytes = Math.max(
256,
options.maxBytes ?? ARTIFACT_TRANSCRIPT_MAX_BYTES,
);
const entryMaxBytes = Math.max(
64,
Math.min(
maxBytes,
options.entryMaxBytes ?? ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES,
),
);
const bounded = transcript.map((entry) => boundEntry(entry, entryMaxBytes));
if (
bounded.reduce((total, entry) => total + textBytes(entry.text), 0) <=
maxBytes
) {
return bounded;
}
const initialIndex = transcript.findIndex((entry) => entry.role === "user");
const initial = boundEntry(
transcript[initialIndex >= 0 ? initialIndex : 0],
Math.min(entryMaxBytes, maxBytes - textBytes(TRANSCRIPT_TRUNCATION_MARKER)),
);
const marker: TranscriptEntry = {
role: "toolResult",
name: "transcript",
text: TRANSCRIPT_TRUNCATION_MARKER,
};
let remaining = maxBytes - textBytes(initial.text) - textBytes(marker.text);
const tail: TranscriptEntry[] = [];
for (
let index = transcript.length - 1;
index >= 0 && remaining > 0;
index--
) {
if (index === initialIndex || (initialIndex < 0 && index === 0)) continue;
const entry = boundEntry(
transcript[index],
Math.min(entryMaxBytes, remaining),
);
tail.push(entry);
remaining -= textBytes(entry.text);
}
tail.reverse();
return [initial, marker, ...tail];
}
function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
export function persistWorkflowJson(runDir: string, details: WorkflowDetails) {
const transcripts = Object.fromEntries(
details.agents.map((agent) => [
agent.index,
boundedArtifactTranscript(agent.transcript),
]),
);
writeRunFile(
runDir,
"transcripts.json",
safeStringify(transcripts, { maxBytes: 2 * 1024 * 1024 }),
);
if (details.result !== undefined) {
writeRunFile(
runDir,
"result.json",
safeStringify(details.result, { maxBytes: 1024 * 1024 }),
);
}
const compact: WorkflowDetails = {
...details,
...(details.result !== undefined
? { result: "[stored in result.json]", resultArtifact: "result.json" }
: {}),
transcriptArtifact: "transcripts.json",
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
};
writeRunFile(
runDir,
"workflow.json",
safeStringify(compact, { maxBytes: 1024 * 1024 }),
);
}
/** Coalesce live checkpoints while keeping final persistence synchronous. */
export function createWorkflowPersistence(
runDir: string,
details: WorkflowDetails,
options: {
intervalMs?: number;
persist?: (runDir: string, details: WorkflowDetails) => void;
} = {},
) {
const intervalMs = Math.max(
0,
options.intervalMs ?? WORKFLOW_CHECKPOINT_INTERVAL_MS,
);
const persist = options.persist ?? persistWorkflowJson;
let lastPersistedAt = Date.now();
let dirty = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const savePending = () => {
timer = undefined;
if (!dirty) return;
try {
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
} catch {
// Final flush retries and reports persistence failures synchronously.
}
};
return {
checkpoint(options: { immediate?: boolean } = {}) {
dirty = true;
if (options.immediate) {
if (timer) clearTimeout(timer);
timer = undefined;
savePending();
return;
}
if (timer) return;
const delay = Math.max(0, intervalMs - (Date.now() - lastPersistedAt));
if (delay === 0) {
savePending();
return;
}
timer = setTimeout(savePending, delay);
},
flush() {
if (timer) clearTimeout(timer);
timer = undefined;
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
},
};
}
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { MAX_AGENT_CALLS, RunController } from "./controller.ts";
const delay = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
test("RunController reserves calls synchronously and caps global fanout", async () => {
const controller = new RunController(undefined, 4);
let active = 0;
let peak = 0;
const tasks = Array.from({ length: 12 }, (_, index) =>
controller.schedule(async () => {
active++;
peak = Math.max(peak, active);
await delay(5);
active--;
return index;
}),
);
assert.deepEqual(
await Promise.all(tasks),
Array.from({ length: 12 }, (_, i) => i),
);
assert.equal(peak, 4);
assert.equal(await controller.settle(), true);
});
test("RunController propagates invocation cancellation without aborting the run", async () => {
const controller = new RunController(undefined, 1);
const invocation = new AbortController();
const pending = controller.schedule(
(signal) =>
new Promise<string>((resolve) => {
signal.addEventListener("abort", () => resolve("stopped"), {
once: true,
});
}),
invocation.signal,
);
invocation.abort(new Error("Workflow agent request was cancelled"));
await assert.rejects(pending, /request was cancelled/);
assert.equal(controller.signal.aborted, false);
assert.equal(await controller.schedule(async () => "recovered"), "recovered");
assert.equal(await controller.settle(), true);
});
test("RunController enforces call budget and aborts queued tasks", async () => {
const controller = new RunController(undefined, 1);
const blocker = controller.schedule(
(signal) =>
new Promise<void>((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true }),
),
);
const queued = Array.from({ length: MAX_AGENT_CALLS - 1 }, () =>
controller.schedule(async () => "queued"),
);
await assert.rejects(
controller.schedule(async () => "too many"),
/exceeded the limit/,
);
controller.abort();
await blocker;
const results = await Promise.allSettled(queued);
assert.ok(results.every((result) => result.status === "rejected"));
assert.equal(await controller.settle({ abort: true }), true);
});
@@ -0,0 +1,192 @@
const DEFAULT_CONCURRENCY = 4;
export const MAX_AGENT_CALLS = 32;
export const RUN_SHUTDOWN_TIMEOUT_MS = 8_000;
function abortError(signal: AbortSignal) {
return signal.reason instanceof Error
? signal.reason
: new Error("Workflow was aborted");
}
class Semaphore {
private active = 0;
private readonly limit: number;
private queue: Array<{
resolve: () => void;
reject: (error: Error) => void;
signal: AbortSignal;
onAbort: () => void;
}> = [];
constructor(limit: number) {
this.limit = limit;
}
acquire(signal: AbortSignal) {
if (signal.aborted) return Promise.reject(abortError(signal));
if (this.active < this.limit) {
this.active++;
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const waiter = {
resolve: () => {
signal.removeEventListener("abort", onAbort);
this.active++;
resolve();
},
reject,
signal,
onAbort: () => {},
};
const onAbort = () => {
const index = this.queue.indexOf(waiter);
if (index >= 0) this.queue.splice(index, 1);
reject(abortError(signal));
};
waiter.onAbort = onAbort;
this.queue.push(waiter);
signal.addEventListener("abort", onAbort, { once: true });
});
}
release() {
this.active = Math.max(0, this.active - 1);
while (this.queue.length > 0) {
const waiter = this.queue.shift()!;
if (waiter.signal.aborted) {
waiter.signal.removeEventListener("abort", waiter.onAbort);
waiter.reject(abortError(waiter.signal));
continue;
}
waiter.resolve();
return;
}
}
clear() {
const queued = this.queue;
this.queue = [];
for (const waiter of queued) {
waiter.signal.removeEventListener("abort", waiter.onAbort);
waiter.reject(abortError(waiter.signal));
}
}
}
/** Owns every agent task and the run-wide fanout/abort budget. */
export class RunController {
private readonly abortController = new AbortController();
private readonly semaphore: Semaphore;
private readonly tasks = new Set<Promise<unknown>>();
private callCount = 0;
private sealed = false;
private parentAbort?: () => void;
private parentSignal?: AbortSignal;
constructor(parentSignal?: AbortSignal, concurrency = DEFAULT_CONCURRENCY) {
this.semaphore = new Semaphore(
Math.max(1, Math.min(DEFAULT_CONCURRENCY, Math.floor(concurrency))),
);
if (parentSignal) {
this.parentSignal = parentSignal;
this.parentAbort = () => this.abort("Parent operation was aborted");
if (parentSignal.aborted) this.parentAbort();
else
parentSignal.addEventListener("abort", this.parentAbort, {
once: true,
});
}
}
get signal() {
return this.abortController.signal;
}
get calls() {
return this.callCount;
}
schedule<T>(
task: (signal: AbortSignal) => Promise<T>,
invocationSignal?: AbortSignal,
): Promise<T> {
if (this.sealed) return Promise.reject(new Error("Workflow is settling"));
if (this.signal.aborted) return Promise.reject(abortError(this.signal));
if (this.callCount >= MAX_AGENT_CALLS) {
return Promise.reject(
new Error(
`Workflow exceeded the limit of ${MAX_AGENT_CALLS} agent calls`,
),
);
}
this.callCount++;
const running = (async () => {
const taskAbort = new AbortController();
const onRunAbort = () => taskAbort.abort(this.signal.reason);
const onInvocationAbort = () => taskAbort.abort(invocationSignal?.reason);
this.signal.addEventListener("abort", onRunAbort, { once: true });
invocationSignal?.addEventListener("abort", onInvocationAbort, {
once: true,
});
if (this.signal.aborted) onRunAbort();
else if (invocationSignal?.aborted) onInvocationAbort();
let acquired = false;
try {
await this.semaphore.acquire(taskAbort.signal);
acquired = true;
if (taskAbort.signal.aborted) throw abortError(taskAbort.signal);
const result = await task(taskAbort.signal);
if (invocationSignal?.aborted) throw abortError(invocationSignal);
return result;
} finally {
this.signal.removeEventListener("abort", onRunAbort);
invocationSignal?.removeEventListener("abort", onInvocationAbort);
if (acquired) this.semaphore.release();
}
})();
this.tasks.add(running);
void running.finally(() => this.tasks.delete(running)).catch(() => {});
return running;
}
abort(reason = "Workflow was aborted") {
if (!this.signal.aborted) this.abortController.abort(new Error(reason));
this.semaphore.clear();
}
/** Seal the task registry and wait a bounded time for every task to settle. */
async settle(options: { abort?: boolean; timeoutMs?: number } = {}) {
this.sealed = true;
if (options.abort) this.abort();
const tasks = [...this.tasks];
if (tasks.length === 0) {
this.detachParent();
return true;
}
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<false>((resolve) => {
timer = setTimeout(
() => resolve(false),
options.timeoutMs ?? RUN_SHUTDOWN_TIMEOUT_MS,
);
timer.unref?.();
});
const settled = Promise.allSettled(tasks).then(() => true as const);
const completed = await Promise.race([settled, timeout]);
if (timer) clearTimeout(timer);
this.detachParent();
return completed;
}
private detachParent() {
if (this.parentAbort) {
this.parentSignal?.removeEventListener("abort", this.parentAbort);
}
this.parentAbort = undefined;
this.parentSignal = undefined;
}
}
File diff suppressed because it is too large Load Diff
+871
View File
@@ -0,0 +1,871 @@
/**
* workflows: model-authored multi-agent orchestration.
*
* A `workflow` tool that runs a JavaScript orchestration script written inline
* by the model. The script executes ordered phases, fanning work out to
* isolated subagents:
*
* export const meta = { name, description, phases: [{ title, detail? }] }
* phase(title) // mark runtime phase progression
* await agent(prompt, { label?, phase?, schema?, model?, provider?, effort? })
* await parallel([() => agent(...), ...], { concurrency? })
* args // parsed JSON args passed with the tool call
*
* `agent()` always resolves to `{ ok, output, structured?, error? }` — it
* never throws into the script. Scripts branch on `ok` explicitly.
*
* Runs are blocking by default (live progress in the tool block). Pass
* `background: true` to return immediately and get a follow-up message when
* the run finishes. Run artifacts (script, args, statuses, result) are saved
* under `~/.pi/agent/workflows/<runId>/` for inspection; result and bounded
* transcripts use separate artifacts, and there is no resume.
*/
import { randomBytes } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import {
getAgentDir,
getMarkdownTheme,
keyHint,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type, type Static } from "typebox";
import { formatActivityStatus } from "../shared/activity-status.ts";
import { createWorkflowPersistence, persistWorkflowJson } from "./artifacts.ts";
import { RunController } from "./controller.ts";
import { sessionWorkflowRunIds, showWorkflowDashboard } from "./dashboard.ts";
import {
extractMeta,
prepareWorkflowScript,
type WorkflowMeta,
} from "./meta.ts";
import {
agentContext,
aggregateUsage,
countStates,
emptyUsage,
formatElapsed,
formatUsage,
phaseGroups,
resultJson,
stateSquare,
statusColor,
statusWord,
SQUARE,
type AgentRecord,
type WorkflowDetails,
} from "./model.ts";
import {
buildBackgroundWorkflowFollowUp,
buildBackgroundWorkflowLaunchResult,
buildWorkflowAgentPrompt,
buildWorkflowResultMessage,
WORKFLOW_PARAMETER_DESCRIPTIONS,
WORKFLOW_PROMPT_GUIDELINES,
WORKFLOW_PROMPT_SNIPPET,
WORKFLOW_TOOL_DESCRIPTION,
} from "./prompt.ts";
import {
createWorkflowResources,
runAgent,
type ThinkingLevel,
type WorkflowModel,
} from "./runner.ts";
import { runWorkflowSandbox } from "./sandbox.ts";
import { safeStringify, writeFileAtomic } from "./serialization.ts";
const PREVIEW_LENGTH = 200;
const EMIT_INTERVAL_MS = 120;
const THINKING_LEVELS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
/** What `agent()` resolves to inside the script. */
interface ScriptAgentResult {
ok: boolean;
output: string;
structured?: unknown;
error?: string;
}
interface AgentCallOptions {
label?: unknown;
phase?: unknown;
schema?: unknown;
model?: unknown;
provider?: unknown;
effort?: unknown;
}
const WorkflowParams = Type.Object({
script: Type.String({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.script,
}),
args: Type.Optional(
Type.String({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.args,
}),
),
background: Type.Optional(
Type.Boolean({
description: WORKFLOW_PARAMETER_DESCRIPTIONS.background,
}),
),
});
type WorkflowInput = Static<typeof WorkflowParams>;
function errorText(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).slice(
0,
16 * 1024,
);
}
function summaryLine(details: WorkflowDetails): string {
const { done, failed } = countStates(details);
const settled = done + failed;
return `workflow ${details.name ?? details.runId}: ${settled}/${details.agents.length} agents${
details.currentPhase ? ` · ${details.currentPhase}` : ""
}`;
}
function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
function compactToolDetails(details: WorkflowDetails): WorkflowDetails {
return {
...details,
...(details.result !== undefined
? {
result: JSON.parse(
safeStringify(details.result, { maxBytes: 64 * 1024 }),
),
}
: {}),
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
};
}
interface RunSummary {
runId: string;
name?: string;
status: string;
done: number;
total: number;
startedAt: number;
active: boolean;
}
function listRuns(
activeRuns: Map<string, WorkflowDetails>,
sessionId: string,
referencedRunIds: ReadonlySet<string>,
): RunSummary[] {
const base = path.join(getAgentDir(), "workflows");
let names: string[] = [];
try {
names = fs.readdirSync(base).filter((name) => name.startsWith("wf_"));
} catch {
// No runs yet.
}
const summaries: RunSummary[] = [];
for (const runId of names) {
const live = activeRuns.get(runId);
if (live) {
const { done, failed } = countStates(live);
summaries.push({
runId,
name: live.name,
status: live.status,
done: done + failed,
total: live.agents.length,
startedAt: live.startedAt,
active: true,
});
continue;
}
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(base, runId, "workflow.json"), "utf8"),
) as Partial<WorkflowDetails>;
if (parsed.sessionId !== sessionId && !referencedRunIds.has(runId)) {
continue;
}
const agents = parsed.agents ?? [];
summaries.push({
runId,
name: parsed.name,
status:
parsed.status === "running"
? "aborted"
: (parsed.status ?? "unknown"),
done: agents.filter((agent) => agent.state !== "running").length,
total: agents.length,
startedAt: parsed.startedAt ?? 0,
active: false,
});
} catch {
// Ignore unreadable artifacts because their session cannot be verified.
}
}
return summaries.sort((a, b) => b.startedAt - a.startedAt);
}
function runDetailText(
run: RunSummary,
activeRuns: Map<string, WorkflowDetails>,
): string {
const runDir = path.join(getAgentDir(), "workflows", run.runId);
const live = activeRuns.get(run.runId);
if (live) return buildWorkflowResultMessage(live, runDir);
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(runDir, "workflow.json"), "utf8"),
) as WorkflowDetails;
return buildWorkflowResultMessage(parsed, runDir);
} catch {
return `Run ${run.runId} — ${run.status}`;
}
}
export default function workflows(pi: ExtensionAPI) {
/** Live background runs, for /workflows and shutdown cleanup. */
const activeRuns = new Map<
string,
{
details: WorkflowDetails;
controller: RunController;
completion?: Promise<void>;
}
>();
const activeDetails = () =>
new Map(
[...activeRuns].map(([runId, run]) => [runId, run.details] as const),
);
/** Finished counts remain visible until the dashboard acknowledges them. */
let lastUi: ExtensionContext["ui"] | undefined;
let completedRuns = 0;
let failedRuns = 0;
const updateIndicator = () => {
const ui = lastUi;
if (!ui) return;
try {
const running = activeRuns.size;
if (running === 0 && completedRuns === 0 && failedRuns === 0) {
ui.setStatus("workflows", undefined);
return;
}
ui.setStatus(
"workflows",
formatActivityStatus(ui.theme, "workflows", {
running,
done: completedRuns,
failed: failedRuns,
}),
);
} catch {
// UI may be unavailable.
}
};
const recordSettledRun = (status: WorkflowDetails["status"]) => {
if (status === "completed") completedRuns += 1;
else failedRuns += 1;
};
pi.on("session_start", (_event, ctx) => {
if (ctx.hasUI) lastUi = ctx.ui;
updateIndicator();
});
pi.on("session_shutdown", async () => {
const runs = [...activeRuns.values()];
for (const run of runs) run.controller.abort("Session is shutting down");
await Promise.all(
runs.map((run) => run.controller.settle({ abort: true })),
);
const completions = runs
.map((run) => run.completion)
.filter(
(completion): completion is Promise<void> => completion !== undefined,
);
if (completions.length > 0) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, 8_000);
timer.unref?.();
});
await Promise.race([Promise.allSettled(completions), timeout]);
if (timer) clearTimeout(timer);
}
lastUi?.setStatus("workflows", undefined);
lastUi = undefined;
});
pi.registerCommand("workflows", {
description:
"List workflow runs (`/workflows <runId>` for one run's detail)",
handler: async (rawArgs, ctx) => {
const arg = rawArgs.trim();
if (ctx.mode === "tui") {
lastUi = ctx.ui;
await showWorkflowDashboard(ctx, activeDetails, arg || undefined);
// Opening the dashboard acknowledges finished runs.
completedRuns = 0;
failedRuns = 0;
updateIndicator();
return;
}
// Non-TUI fallback: plain text listing.
const runs = listRuns(
activeDetails(),
ctx.sessionManager.getSessionId(),
sessionWorkflowRunIds(ctx),
);
if (runs.length === 0) {
ctx.ui.notify("No workflow runs yet.", "info");
return;
}
if (arg) {
const run = runs.find((r) => r.runId === arg || r.runId.endsWith(arg));
ctx.ui.notify(
run
? runDetailText(run, activeDetails())
: `No workflow run matching "${arg}".`,
run ? "info" : "warning",
);
return;
}
const labels = runs.map(
(r) =>
`${r.active ? "* " : " "}${r.runId} ${r.status} ${r.name ?? ""} ${r.done}/${r.total}`,
);
if (!ctx.hasUI) {
ctx.ui.notify(labels.join("\n"), "info");
return;
}
const choice = await ctx.ui.select("Workflow runs", labels);
if (!choice) return;
const run = runs[labels.indexOf(choice)];
if (run) ctx.ui.notify(runDetailText(run, activeDetails()), "info");
},
});
pi.registerTool({
name: "workflow",
label: "Workflow",
description: WORKFLOW_TOOL_DESCRIPTION,
promptSnippet: WORKFLOW_PROMPT_SNIPPET,
promptGuidelines: WORKFLOW_PROMPT_GUIDELINES,
parameters: WorkflowParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
let prepared: ReturnType<typeof prepareWorkflowScript>;
try {
prepared = prepareWorkflowScript(params.script);
} catch (error) {
throw new Error(`Workflow script failed to parse: ${errorText(error)}`);
}
let args: unknown;
if (params.args !== undefined) {
try {
args = JSON.parse(params.args);
} catch {
args = params.args;
}
}
const meta = prepared.meta;
const runId = `wf_${randomBytes(6).toString("hex")}`;
const runDir = path.join(getAgentDir(), "workflows", runId);
const background = (params.background ?? false) && ctx.hasUI;
const details: WorkflowDetails = {
runId,
sessionId: ctx.sessionManager.getSessionId(),
name: meta.name,
description: meta.description,
background,
status: "running",
startedAt: Date.now(),
phases: [...meta.phases],
agents: [],
};
writeRunFile(runDir, "script.js", params.script);
if (params.args !== undefined)
writeRunFile(runDir, "args.json", params.args);
persistWorkflowJson(runDir, details);
const persistence = createWorkflowPersistence(runDir, details);
// Background runs survive Esc on the parent turn, but all runs are
// aborted and settled during session shutdown.
const controller = new RunController(background ? undefined : signal);
// Each concurrent child gets its own extension runtime. All children use
// the parent cwd and live trust decision.
const projectTrusted = ctx.isProjectTrusted();
const getResources = (structured: boolean) =>
createWorkflowResources(
ctx.cwd,
structured ? "structured" : "plain",
projectTrusted,
);
// Throttled progress: tool-block updates when blocking. Background
// runs are covered by the below-editor indicator and /workflows.
let emitTimer: ReturnType<typeof setTimeout> | undefined;
let lastEmit = 0;
const flush = () => {
emitTimer = undefined;
lastEmit = Date.now();
if (background) return;
onUpdate?.({
content: [{ type: "text", text: summaryLine(details) }],
details: compactToolDetails(details),
});
};
const emit = (checkpoint = true) => {
if (checkpoint) persistence.checkpoint();
if (emitTimer) return;
emitTimer = setTimeout(
flush,
Math.max(0, EMIT_INTERVAL_MS - (Date.now() - lastEmit)),
);
};
const flushNow = () => {
if (emitTimer) clearTimeout(emitTimer);
flush();
};
const phaseFn = (title: unknown) => {
const text = String(title);
details.currentPhase = text;
if (!details.phases.some((p) => p.title === text))
details.phases.push({ title: text });
emit();
};
let agentCounter = 0;
const agentFn = async (
promptValue: unknown,
optsValue: unknown = {},
invocationSignal?: AbortSignal,
): Promise<ScriptAgentResult> => {
const index = ++agentCounter;
const opts: AgentCallOptions =
optsValue && typeof optsValue === "object"
? (optsValue as AgentCallOptions)
: {};
const label =
typeof opts.label === "string" && opts.label.trim()
? opts.label.trim().slice(0, 160)
: `agent-${index}`;
const record: AgentRecord = {
index,
label,
phase:
typeof opts.phase === "string"
? opts.phase.slice(0, 160)
: details.currentPhase,
state: "running",
model: ctx.model?.id,
contextWindow: ctx.model?.contextWindow,
startedAt: Date.now(),
preview: "",
usage: emptyUsage(),
transcript: [],
};
details.agents.push(record);
persistence.checkpoint({ immediate: true });
emit(false);
const fail = (error: string): ScriptAgentResult => {
record.state = "error";
record.error = error;
record.finishedAt = Date.now();
emit();
return { ok: false, output: "", error };
};
const prompt = buildWorkflowAgentPrompt(
typeof promptValue === "string"
? promptValue
: String(promptValue ?? ""),
);
if (!prompt.trim())
return fail("agent() requires a non-empty prompt string");
if (controller.signal.aborted)
return fail("Workflow was aborted before this agent started");
return controller
.schedule(async (runSignal) => {
// Model/provider resolution: default to the parent session's model.
let model: WorkflowModel | undefined = ctx.model;
if (opts.model !== undefined || opts.provider !== undefined) {
const modelOpt =
typeof opts.model === "string" ? opts.model : undefined;
const providerOpt =
typeof opts.provider === "string" ? opts.provider : undefined;
if (!modelOpt)
return fail(
`agent "${label}": \`provider\` requires \`model\` as well`,
);
let resolved: WorkflowModel | undefined;
if (providerOpt) {
resolved = ctx.modelRegistry.find(providerOpt, modelOpt);
} else {
const slash = modelOpt.indexOf("/");
if (slash > 0) {
resolved = ctx.modelRegistry.find(
modelOpt.slice(0, slash),
modelOpt.slice(slash + 1),
);
}
resolved ??= ctx.modelRegistry
.getAll()
.find((m) => m.id === modelOpt);
}
if (!resolved) {
const requested = providerOpt
? `${providerOpt}/${modelOpt}`
: modelOpt;
return fail(
`agent "${label}": unknown model "${requested}" (use provider/id)`,
);
}
model = resolved;
}
record.model = model?.id;
record.contextWindow = model?.contextWindow;
emit();
// Effort → thinking level; default inherits the parent session.
let thinkingLevel: ThinkingLevel = pi.getThinkingLevel();
if (opts.effort !== undefined) {
const effort = String(opts.effort);
if (!(THINKING_LEVELS as readonly string[]).includes(effort)) {
return fail(
`agent "${label}": invalid effort "${effort}" (use ${THINKING_LEVELS.join("|")})`,
);
}
thinkingLevel = effort as ThinkingLevel;
}
const resources = await getResources(opts.schema !== undefined);
const outcome = await runAgent({
prompt,
schema: opts.schema,
model,
thinkingLevel,
cwd: ctx.cwd,
loader: resources.loader,
settingsManager: resources.settingsManager,
modelRegistry: ctx.modelRegistry,
signal: runSignal,
onProgress: (progress) => {
record.preview = progress.preview.slice(0, PREVIEW_LENGTH);
record.usage = progress.usage;
record.model = progress.model ?? record.model;
record.contextWindow =
progress.contextWindow ?? record.contextWindow;
record.transcript = progress.transcript;
emit();
},
});
record.usage = outcome.usage;
record.model = outcome.model ?? record.model;
record.contextWindow =
outcome.contextWindow ?? record.contextWindow;
record.transcript = outcome.transcript;
record.preview = (outcome.output || record.preview).slice(
0,
PREVIEW_LENGTH,
);
record.finishedAt = Date.now();
record.state = outcome.ok ? "done" : "error";
if (outcome.ok) {
delete record.error;
} else {
record.error = outcome.error ?? "Agent failed";
}
emit();
return {
ok: outcome.ok,
output: outcome.output,
...(outcome.structured !== undefined
? { structured: outcome.structured }
: {}),
...(outcome.error !== undefined ? { error: outcome.error } : {}),
};
}, invocationSignal)
.catch((error) => fail(errorText(error)));
};
const runScript = async () => {
let status: WorkflowDetails["status"] = "completed";
try {
details.result = await runWorkflowSandbox({
source: prepared.source,
args,
cwd: ctx.cwd,
signal: controller.signal,
onAgent: agentFn,
onPhase: phaseFn,
});
} catch (error) {
details.error = errorText(error);
status = controller.signal.aborted ? "aborted" : "failed";
controller.abort("Workflow script failed");
}
const settled = await controller.settle({
abort: status !== "completed",
});
if (!settled) {
status = "failed";
details.error = details.error
? `${details.error}; agent shutdown deadline exceeded`
: "Agent shutdown deadline exceeded";
}
for (const record of details.agents) {
if (record.state !== "running") continue;
record.state = "error";
record.error =
record.error ?? "Agent did not settle before run cleanup";
record.finishedAt = Date.now();
}
details.status = status;
details.finishedAt = Date.now();
try {
persistence.flush();
} catch (error) {
details.status = "failed";
details.error = `Artifact persistence failed: ${errorText(error)}`;
throw new Error(details.error);
} finally {
flushNow();
}
};
// Registered for /workflows visibility and session_shutdown abort;
// blocking runs are watchable live from the dashboard too.
const activeRun = { details, controller } as {
details: WorkflowDetails;
controller: RunController;
completion?: Promise<void>;
};
activeRuns.set(runId, activeRun);
const completion = runScript();
activeRun.completion = completion;
if (ctx.hasUI) lastUi = ctx.ui;
updateIndicator();
if (background) {
void completion
.catch((error) => {
details.status = "failed";
details.finishedAt = Date.now();
details.error = details.error ?? errorText(error);
})
.finally(() => {
activeRuns.delete(runId);
recordSettledRun(details.status);
updateIndicator();
try {
pi.sendUserMessage(
buildBackgroundWorkflowFollowUp({
runId,
status: details.status,
result: buildWorkflowResultMessage(details, runDir),
}),
{ deliverAs: "followUp" },
);
} catch {
// Session may be shutting down.
}
});
return {
content: [
{
type: "text",
text: buildBackgroundWorkflowLaunchResult({
runId,
name: details.name,
runDir,
}),
},
],
details: compactToolDetails(details),
};
}
try {
await completion;
} finally {
activeRuns.delete(runId);
recordSettledRun(details.status);
updateIndicator();
}
if (details.status !== "completed") {
// Pi marks tool failures only when execute throws; returning isError is
// ignored by the extension API.
throw new Error(buildWorkflowResultMessage(details, runDir));
}
return {
content: [
{
type: "text",
text: buildWorkflowResultMessage(details, runDir),
},
],
details: compactToolDetails(details),
};
},
renderCall(args: Partial<WorkflowInput>, theme) {
const meta =
typeof args.script === "string"
? extractMeta(args.script)
: { phases: [] };
let text =
theme.fg("toolTitle", theme.bold("workflow ")) +
theme.fg("accent", (meta as WorkflowMeta).name ?? "(script)");
if (args.background) text += theme.fg("dim", " (background)");
const description = (meta as WorkflowMeta).description;
if (description) text += `\n ${theme.fg("dim", description)}`;
for (const phase of meta.phases.slice(0, 8)) {
text += `\n ${theme.fg("dim", SQUARE)} ${theme.fg("accent", phase.title)}${
phase.detail ? theme.fg("dim", ` — ${phase.detail}`) : ""
}`;
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as WorkflowDetails | undefined;
if (!details) {
const first = result.content[0];
return new Text(
first?.type === "text" ? first.text : "(no output)",
0,
0,
);
}
const { done, failed } = countStates(details);
const settled = done + failed;
const elapsed = formatElapsed(details.startedAt, details.finishedAt);
let header =
`${theme.fg(statusColor(details.status), SQUARE)} ${theme.fg("toolTitle", theme.bold("workflow "))}` +
`${theme.fg("accent", details.name ?? details.runId)} ` +
theme.fg(
"dim",
`${settled}/${details.agents.length} agents · ${elapsed} · `,
) +
theme.fg(statusColor(details.status), statusWord(details.status));
if (failed) header += theme.fg("error", ` · ${failed} failed`);
if (details.background) header += theme.fg("dim", " (background)");
if (details.status === "running" && details.currentPhase) {
header += theme.fg("muted", ` · ${details.currentPhase}`);
}
const totals = formatUsage(aggregateUsage(details.agents));
if (!expanded) {
let text = header;
for (const agent of details.agents) {
const context = agentContext(agent);
text += `\n ${stateSquare(agent.state, theme)} ${theme.fg("accent", agent.label)}${
agent.phase ? theme.fg("dim", ` (${agent.phase})`) : ""
}${theme.fg(
"dim",
`${context ? ` · ${context}` : ""} · ${formatElapsed(agent.startedAt, agent.finishedAt)}`,
)}`;
}
if (totals) text += `\n ${theme.fg("dim", `Total: ${totals}`)}`;
if (details.error)
text += `\n ${theme.fg("error", `Error: ${details.error}`)}`;
text += `\n${theme.fg("muted", `(${keyHint("app.tools.expand", "to expand")})`)}`;
return new Text(text, 0, 0);
}
const container = new Container();
container.addChild(new Text(header, 0, 0));
if (details.description) {
container.addChild(
new Text(theme.fg("dim", details.description), 0, 0),
);
}
for (const group of phaseGroups(details)) {
container.addChild(new Spacer(1));
container.addChild(
new Text(theme.fg("muted", `─── ${group.title} ───`), 0, 0),
);
for (const agent of group.agents) {
const usage = formatUsage(agent.usage, agent.model);
const context = agentContext(agent);
let line = `${stateSquare(agent.state, theme)} ${theme.fg("accent", agent.label)} ${theme.fg(
"dim",
[context, formatElapsed(agent.startedAt, agent.finishedAt)]
.filter(Boolean)
.join(" · "),
)}`;
if (usage) line += ` ${theme.fg("dim", usage)}`;
container.addChild(new Text(line, 0, 0));
if (agent.error) {
container.addChild(
new Text(` ${theme.fg("error", agent.error)}`, 0, 0),
);
} else if (agent.preview) {
const preview = agent.preview.split("\n").slice(0, 2).join(" ");
container.addChild(new Text(` ${theme.fg("dim", preview)}`, 0, 0));
}
}
}
if (details.error) {
container.addChild(new Spacer(1));
container.addChild(
new Text(theme.fg("error", `Error: ${details.error}`), 0, 0),
);
}
if (details.result !== undefined) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("muted", "─── result ───"), 0, 0));
container.addChild(
new Markdown(
`\`\`\`json\n${resultJson(details.result)}\n\`\`\``,
0,
0,
getMarkdownTheme(),
),
);
}
if (totals) {
container.addChild(new Spacer(1));
container.addChild(new Text(theme.fg("dim", `Total: ${totals}`), 0, 0));
}
return container;
},
});
}
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { extractMeta, prepareWorkflowScript } from "./meta.ts";
test("metadata is decoded statically and removed from executable source", () => {
const source = `export const meta = {
name: "audit",
description: "safe",
phases: [{ title: "Scan", detail: "files" }],
};
return { ok: true };`;
const prepared = prepareWorkflowScript(source);
assert.deepEqual(prepared.meta, {
name: "audit",
description: "safe",
phases: [{ title: "Scan", detail: "files" }],
});
assert.doesNotMatch(prepared.source, /name:\s*"audit"/);
assert.equal(prepared.source.split("\n").length, source.split("\n").length);
});
test("export-like text in strings, comments, regexes, and templates is untouched", () => {
const source = `
const string = "export default notSyntax";
const template = \`export const meta = \${string}\`;
const regex = /export\\s+default/;
// export const fake = 1
return { string, template, matches: regex.test(string) };
`;
const prepared = prepareWorkflowScript(source);
assert.equal(prepared.source, source);
assert.deepEqual(prepared.meta, { phases: [] });
});
test("executable and unsupported metadata fail closed", () => {
assert.throws(
() =>
prepareWorkflowScript(
`export const meta = { name: (() => "executed")(), phases: [] }; return 1;`,
),
/only static literals/,
);
assert.throws(
() => prepareWorkflowScript(`export default 1; return 1;`),
/may only export/,
);
assert.deepEqual(
extractMeta(`export const meta = { name: process.exit(), phases: [] }`),
{ phases: [] },
);
});
+237
View File
@@ -0,0 +1,237 @@
import {
parse,
type ArrayExpression,
type ExportNamedDeclaration,
type Expression,
type Identifier,
type Literal,
type ObjectExpression,
type Program,
type Property,
type VariableDeclaration,
} from "acorn";
/** Static workflow metadata and source preparation helpers. */
export interface WorkflowPhase {
title: string;
detail?: string;
}
export interface WorkflowMeta {
name?: string;
description?: string;
phases: WorkflowPhase[];
}
export interface PreparedWorkflowScript {
/** Script body with the metadata declaration removed. */
source: string;
meta: WorkflowMeta;
}
function parseProgram(source: string): Program {
return parse(source, {
ecmaVersion: "latest",
sourceType: "module",
allowReturnOutsideFunction: true,
});
}
function isIdentifier(node: Expression, name?: string): node is Identifier {
return (
node.type === "Identifier" && (name === undefined || node.name === name)
);
}
function isLiteral(node: Expression): node is Literal {
return node.type === "Literal";
}
function isProperty(
node: ObjectExpression["properties"][number],
): node is Property {
return node.type === "Property";
}
function propertyName(property: Property) {
if (property.computed || property.kind !== "init" || property.method) {
return undefined;
}
if (isIdentifier(property.key)) return property.key.name;
if (isLiteral(property.key) && typeof property.key.value === "string") {
return property.key.value;
}
return undefined;
}
/**
* Convert an Acorn expression to data without evaluating source. Only plain
* object/array/primitive literals are accepted. Getters, methods, spreads,
* computed keys, templates, identifiers, and calls all fail closed.
*/
function literalValue(node: Expression, depth = 0): unknown {
if (depth > 8) throw new Error("workflow metadata is nested too deeply");
if (isLiteral(node)) {
if (
node.value === null ||
typeof node.value === "string" ||
typeof node.value === "number" ||
typeof node.value === "boolean"
) {
return node.value;
}
throw new Error("workflow metadata contains an unsupported literal");
}
if (node.type === "ArrayExpression") {
const arrayNode: ArrayExpression = node;
return arrayNode.elements.map((element) => {
if (!element || element.type === "SpreadElement") {
throw new Error(
"workflow metadata arrays cannot contain holes or spreads",
);
}
return literalValue(element, depth + 1);
});
}
if (node.type === "ObjectExpression") {
const objectNode: ObjectExpression = node;
const value: Record<string, unknown> = Object.create(null);
for (const item of objectNode.properties) {
if (!isProperty(item)) {
throw new Error("workflow metadata objects cannot contain spreads");
}
const key = propertyName(item);
if (key === undefined || item.shorthand) {
throw new Error(
"workflow metadata keys and values must be plain literals",
);
}
value[key] = literalValue(item.value, depth + 1);
}
return value;
}
throw new Error("workflow metadata must contain only static literals");
}
function sanitizeMeta(value: unknown): WorkflowMeta {
const meta: WorkflowMeta = { phases: [] };
if (!value || typeof value !== "object") return meta;
const raw = value as {
name?: unknown;
description?: unknown;
phases?: unknown;
};
if (typeof raw.name === "string") meta.name = raw.name.slice(0, 160);
if (typeof raw.description === "string") {
meta.description = raw.description.slice(0, 2_000);
}
if (Array.isArray(raw.phases)) {
for (const item of raw.phases.slice(0, 64)) {
if (!item || typeof item !== "object") continue;
const phase = item as { title?: unknown; detail?: unknown };
if (typeof phase.title !== "string" || !phase.title.trim()) continue;
meta.phases.push({
title: phase.title.slice(0, 160),
...(typeof phase.detail === "string"
? { detail: phase.detail.slice(0, 2_000) }
: {}),
});
}
}
return meta;
}
function metadataDeclaration(statement: Program["body"][number]) {
if (statement.type !== "ExportNamedDeclaration") return undefined;
const exported: ExportNamedDeclaration = statement;
if (
exported.source ||
exported.specifiers.length > 0 ||
exported.declaration?.type !== "VariableDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration: VariableDeclaration = exported.declaration;
if (declaration.kind !== "const" || declaration.declarations.length !== 1) {
throw new Error(
"workflow metadata must be declared as `export const meta = {...}`",
);
}
const declarator = declaration.declarations[0];
if (
declarator.id.type !== "Identifier" ||
declarator.id.name !== "meta" ||
!declarator.init
) {
throw new Error("workflow scripts may only export a `meta` declaration");
}
return { exported, initializer: declarator.init };
}
/**
* Parse a workflow as JavaScript, reject module syntax other than the metadata
* API, statically decode metadata, and remove that declaration from the body.
* Source inside strings, comments, regexes, and templates is never rewritten.
*/
export function prepareWorkflowScript(source: string): PreparedWorkflowScript {
const program = parseProgram(source);
let meta: WorkflowMeta = { phases: [] };
let metadataRange: { start: number; end: number } | undefined;
for (const statement of program.body) {
if (statement.type === "ImportDeclaration") {
throw new Error("workflow scripts cannot use static imports");
}
if (
statement.type === "ExportDefaultDeclaration" ||
statement.type === "ExportAllDeclaration"
) {
throw new Error(
"workflow scripts may only export a static `const meta = {...}` declaration",
);
}
const declaration = metadataDeclaration(statement);
if (!declaration) continue;
if (metadataRange)
throw new Error("workflow metadata may only be declared once");
meta = sanitizeMeta(literalValue(declaration.initializer));
metadataRange = {
start: declaration.exported.start,
end: declaration.exported.end,
};
}
if (!metadataRange) return { source, meta };
// Preserve line numbers for parse/runtime errors while removing every byte of
// executable metadata. A semicolon keeps adjacent statements separated.
const removed = source.slice(metadataRange.start, metadataRange.end);
const replacement = `;${removed.slice(1).replace(/[^\n\r]/g, " ")}`;
return {
source:
source.slice(0, metadataRange.start) +
replacement +
source.slice(metadataRange.end),
meta,
};
}
const metaCache = new Map<string, WorkflowMeta>();
const META_CACHE_LIMIT = 32;
/** Safe metadata extraction for render paths. Invalid/partial scripts are empty. */
export function extractMeta(script: string): WorkflowMeta {
const cached = metaCache.get(script);
if (cached) return cached;
let meta: WorkflowMeta = { phases: [] };
try {
meta = prepareWorkflowScript(script).meta;
} catch {
// Renderers must tolerate partial model output and invalid scripts.
}
if (metaCache.size >= META_CACHE_LIMIT) metaCache.clear();
metaCache.set(script, meta);
return meta;
}
+241
View File
@@ -0,0 +1,241 @@
/**
* Shared workflow run model + formatting helpers, used by the tool renderers
* (index.ts) and the /workflows dashboard (dashboard.ts).
*/
import * as os from "node:os";
import {
truncateHead,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { formatContextUtilization } from "../shared/context-utilization.ts";
import { safeStringify } from "./serialization.ts";
export type Theme = ExtensionContext["ui"]["theme"];
export const RESULT_JSON_MAX_BYTES = 24 * 1024;
export const RESULT_JSON_MAX_LINES = 600;
export interface AgentUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
/** Latest compaction-aware conversation occupancy, not cumulative billing. */
contextTokens?: number;
turns: number;
}
export function emptyUsage(): AgentUsage {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
turns: 0,
};
}
export type AgentState = "running" | "done" | "error";
export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
export type TranscriptRole =
"user" | "assistant" | "thinking" | "tool" | "toolResult";
export interface TranscriptEntry {
role: TranscriptRole;
text: string;
/** Tool name for tool calls/results. */
name?: string;
/** Stable tool-call identifier used to pair calls, results, and timings. */
toolCallId?: string;
isError?: boolean;
/** Original message timestamp, when provided by the model/session. */
timestamp?: number;
/** Tool execution lifecycle timestamps, measured by the child session. */
startedAt?: number;
finishedAt?: number;
durationMs?: number;
}
export interface AgentRecord {
index: number;
label: string;
phase?: string;
state: AgentState;
model?: string;
/** Context capacity of the active model used for this agent. */
contextWindow?: number;
startedAt: number;
finishedAt?: number;
error?: string;
preview: string;
usage: AgentUsage;
/** Normalized, serializable subagent conversation shown by /workflows. */
transcript: TranscriptEntry[];
}
export interface WorkflowDetails {
runId: string;
/** Pi session that launched this run. */
sessionId?: string;
name?: string;
description?: string;
background: boolean;
status: WorkflowStatus;
startedAt: number;
finishedAt?: number;
phases: { title: string; detail?: string }[];
currentPhase?: string;
agents: AgentRecord[];
result?: unknown;
resultArtifact?: string;
transcriptArtifact?: string;
error?: string;
}
/** Colored square state indicator (no emojis/glyphs). */
export const SQUARE = "■";
export function stateSquare(state: AgentState, theme: Theme): string {
if (state === "done") return theme.fg("success", SQUARE);
if (state === "error") return theme.fg("error", SQUARE);
return theme.fg("warning", SQUARE);
}
export function statusSquare(status: WorkflowStatus, theme: Theme): string {
if (status === "completed") return theme.fg("success", SQUARE);
if (status === "running") return theme.fg("warning", SQUARE);
return theme.fg("error", SQUARE);
}
export function statusWord(status: WorkflowStatus): string {
return status === "completed" ? "done" : status;
}
export function statusColor(
status: WorkflowStatus,
): "success" | "warning" | "error" {
if (status === "completed") return "success";
if (status === "running") return "warning";
return "error";
}
export function shortenHome(p: string): string {
const home = os.homedir();
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
}
export function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
export function formatUsage(usage: AgentUsage, model?: string): string {
const parts: string[] = [];
if (usage.turns)
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
if (usage.input) parts.push(`${formatTokens(usage.input)} in`);
if (usage.output) parts.push(`${formatTokens(usage.output)} out`);
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
if (model) parts.push(model);
return parts.join(" · ");
}
/** Current per-agent context-window utilization, e.g. "7%/372k". */
export function agentContext(agent: AgentRecord): string {
return formatContextUtilization({
tokens: agent.usage.contextTokens,
contextWindow: agent.contextWindow,
});
}
export function formatElapsed(startedAt: number, finishedAt?: number): string {
const totalSeconds = Math.max(
0,
Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000),
);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
: `${seconds}s`;
}
export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
const total = emptyUsage();
for (const agent of agents) {
total.input += agent.usage.input;
total.output += agent.usage.output;
total.cacheRead += agent.usage.cacheRead;
total.cacheWrite += agent.usage.cacheWrite;
total.cost += agent.usage.cost;
total.turns += agent.usage.turns;
}
return total;
}
export function countStates(details: WorkflowDetails) {
let done = 0;
let failed = 0;
let running = 0;
for (const agent of details.agents) {
if (agent.state === "done") done++;
else if (agent.state === "error") failed++;
else running++;
}
return { done, failed, running };
}
export interface PhaseGroup {
title: string;
agents: AgentRecord[];
}
/**
* Group agents by phase in declared phase order. With `includeEmpty`, phases
* that have no agents yet are still listed (used by the dashboard sidebar).
*/
export function phaseGroups(
details: WorkflowDetails,
includeEmpty = false,
): PhaseGroup[] {
const byPhase = new Map<string, AgentRecord[]>();
for (const agent of details.agents) {
const key = agent.phase ?? "(unphased)";
const list = byPhase.get(key) ?? [];
list.push(agent);
byPhase.set(key, list);
}
const groups: PhaseGroup[] = [];
for (const phase of details.phases) {
const agents = byPhase.get(phase.title);
if (agents || includeEmpty)
groups.push({ title: phase.title, agents: agents ?? [] });
byPhase.delete(phase.title);
}
for (const [title, agents] of byPhase) groups.push({ title, agents });
return groups;
}
export function resultJson(value: unknown): string {
const text =
typeof value === "string"
? value
: safeStringify(value, {
maxBytes: RESULT_JSON_MAX_BYTES * 2,
maxDepth: 16,
maxNodes: 10_000,
});
const truncation = truncateHead(text ?? "", {
maxLines: RESULT_JSON_MAX_LINES,
maxBytes: RESULT_JSON_MAX_BYTES,
});
return truncation.truncated
? `${truncation.content}\n…[result truncated; bounded result artifact in result.json]`
: truncation.content;
}
+118
View File
@@ -0,0 +1,118 @@
import {
countStates,
formatElapsed,
resultJson,
shortenHome,
type WorkflowDetails,
} from "./model.ts";
/** Model-facing schema descriptions for workflow source, arguments, and background mode. */
export const WORKFLOW_PARAMETER_DESCRIPTIONS = {
script:
"JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.",
args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).",
background:
"Run in the background: the tool returns a run id immediately and you receive a follow-up message when the workflow finishes. Defaults to false (blocking with live progress).",
};
/** Defines the workflow DSL, constraints, reliability guidance, and model-authored task examples. */
export const WORKFLOW_TOOL_DESCRIPTION = [
"The workflow tool is only to be called when the user says 'ultracode' or specifically requests a workflow run.",
"Run a multi-agent workflow from a JavaScript orchestration script you write inline. Use this when a task benefits from fanning work out across several isolated subagents in ordered phases (research fan-out, per-file review, verify-then-synthesize pipelines).",
"The script runs as an async function body with these primitives:",
"• export const meta = { name, description, phases: [{ title, detail? }] } — metadata for the progress UI. Declare all phases up front.",
"• phase(title) — mark the current phase at runtime (use titles from meta.phases).",
"• await agent(prompt, { label?, phase?, schema?, model?, provider?, effort? }) — run ONE subagent in an isolated context and wait for it. Always resolves to { ok, output, structured?, error? }. Check `ok` before using the result. When you pass a JSON `schema`, `structured` holds the validated object on success. `model`/`provider` override the session model; `effort` sets the thinking level (off|minimal|low|medium|high|xhigh|max). Children receive normal built-ins and trust-appropriate extensions, settings, skills, and AGENTS.md context, but cannot recursively orchestrate or ask the user.",
"• await parallel([() => agent(...), () => agent(...)], { concurrency? }) — run zero-argument agent thunks concurrently and return results in order. Concurrency is globally capped at 4 for the run.",
"• args — the parsed value of the `args` tool parameter (or undefined).",
"Workflow JavaScript runs in a restricted, killable child with no imports, eval, timers, filesystem, network, or process APIs. A run may make at most 32 agent calls and has no overall deadline. Each agent must receive its first assistant response event within 45 seconds so silent provider requests fail clearly; after that, agent() has no wall-clock deadline. Each individual child tool call times out independently after 3 minutes, becomes an error tool result, and leaves the agent loop free to recover. Use map/filter/if/await/template strings to orchestrate, and `return` a JSON-serializable aggregate.",
"Pass a `schema` to agent() whenever a later step branches on the result, so you get typed fields instead of prose. There is no resume: a failed run is simply re-run. Artifacts are saved under ~/.pi/agent/workflows/<runId>/ for inspection.",
"Example:",
"export const meta = { name: 'reliability-review', description: 'Review modules for reliability risks, then report', phases: [{ title: 'Scan' }, { title: 'Report' }] }",
"const FINDINGS = { type: 'object', properties: { issues: { type: 'array', items: { type: 'string' } }, ok: { type: 'boolean' } }, required: ['issues', 'ok'] }",
"phase('Scan')",
"const scans = await parallel(args.files.map((f) => () => agent(`Review ${f} for correctness and reliability risks.`, { label: `scan:${f}`, phase: 'Scan', schema: FINDINGS })))",
"const findings = scans.filter((r) => r.ok).map((r) => r.structured)",
"phase('Report')",
"const report = await agent(`Summarize these findings: ${JSON.stringify(findings)}`, { label: 'report', phase: 'Report' })",
"return { findings, report: report.ok ? report.output : report.error }",
].join("\n");
/** Adds workflow orchestration primitives and background execution to the model's tool prompt. */
export const WORKFLOW_PROMPT_SNIPPET =
"Orchestrate isolated subagents from an inline JS script: phase()/agent()/parallel() with structured outputs and optional background execution";
/** Guides the model on appropriate workflow fan-out and mandatory agent result checks. */
export const WORKFLOW_PROMPT_GUIDELINES = [
"Use workflow when a task needs several subagents with phase dependencies or dynamic fan-out; keep single small delegations in the main session.",
"In workflow scripts, agent() never throws — always check `.ok` on its result before using `.output`/`.structured`.",
];
/** Marks and forwards a workflow script's agent() task as an isolated child-model prompt. */
export function buildWorkflowAgentPrompt(prompt: string) {
return prompt;
}
/** Instructs structured workflow children to terminate with exactly one structured_output call. */
export const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION =
"When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it.";
/** Describes the terminating structured_output tool and its final-action contract. */
export const STRUCTURED_OUTPUT_TOOL_DESCRIPTION =
"Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it.";
/** Builds the workflow completion report returned to the parent model. */
export function buildWorkflowResultMessage(
details: WorkflowDetails,
runDir: string,
) {
const { done, failed } = countStates(details);
const elapsed = formatElapsed(details.startedAt, details.finishedAt);
const lines = [
`Workflow ${details.name ? `"${details.name}"` : details.runId} ${details.status} — ` +
`${done}/${details.agents.length} agents ok${failed ? `, ${failed} failed` : ""} ` +
`across ${details.phases.length} phase(s) in ${elapsed}.`,
`Run dir: ${shortenHome(runDir)}`,
];
if (details.error) lines.push(`Error: ${details.error}`);
if (details.agents.length > 0) {
lines.push("", "Agents:");
for (const agent of details.agents) {
const status =
agent.state === "done"
? "ok"
: agent.state === "error"
? "FAILED"
: "running";
lines.push(
`- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${status}` +
(agent.error ? ` — ${agent.error}` : ""),
);
}
}
if (details.result !== undefined)
lines.push("", "Result:", resultJson(details.result));
return lines.join("\n");
}
/** Builds the follow-up user message that delivers a settled background workflow to the parent model. */
export function buildBackgroundWorkflowFollowUp(options: {
runId: string;
status: WorkflowDetails["status"];
result: string;
}) {
return `[Background workflow ${options.runId} ${options.status}]\n\n${options.result}`;
}
/** Builds the background-launch result and tells the parent model where progress and artifacts appear. */
export function buildBackgroundWorkflowLaunchResult(options: {
runId: string;
name?: string;
runDir: string;
}) {
return [
`Workflow ${options.name ? `"${options.name}"` : options.runId} launched in background (run ${options.runId}).`,
`Artifacts: ${shortenHome(options.runDir)}`,
"You'll receive a follow-up message when it finishes; /workflows shows progress.",
].join("\n");
}
@@ -0,0 +1,273 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
AgentSession,
AgentSessionEventListener,
ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
createFirstResponseWatchdog,
guardWorkflowChildTools,
recordToolExecutionTiming,
transcriptFromMessages,
type ToolExecutionTiming,
} from "./runner.ts";
const zeroUsage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
function parallelToolMessages(): AgentSession["messages"] {
return [
{ role: "user", content: "run both", timestamp: 900 },
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-a",
name: "first",
arguments: { value: 1 },
},
{
type: "toolCall",
id: "call-b",
name: "second",
arguments: { value: 2 },
},
],
api: "openai-responses",
provider: "fixture",
model: "fixture",
usage: zeroUsage,
stopReason: "toolUse",
timestamp: 950,
},
{
role: "toolResult",
toolCallId: "call-a",
toolName: "first",
content: [{ type: "text", text: "first result" }],
isError: false,
timestamp: 1_040,
},
{
role: "toolResult",
toolCallId: "call-b",
toolName: "second",
content: [{ type: "text", text: "second result" }],
isError: false,
timestamp: 1_041,
},
];
}
test("completed parallel tool calls pair lifecycle timings with calls and results", () => {
const timings = new Map<string, ToolExecutionTiming>();
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-a",
toolName: "first",
args: { value: 1 },
},
1_000,
);
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-b",
toolName: "second",
args: { value: 2 },
},
1_002,
);
// Parallel calls can finish in a different order than their result messages.
recordToolExecutionTiming(
timings,
{
type: "tool_execution_end",
toolCallId: "call-b",
toolName: "second",
result: { content: [{ type: "text", text: "second result" }] },
isError: false,
},
1_012,
);
recordToolExecutionTiming(
timings,
{
type: "tool_execution_end",
toolCallId: "call-a",
toolName: "first",
result: { content: [{ type: "text", text: "first result" }] },
isError: false,
},
1_030,
);
const transcript = transcriptFromMessages(parallelToolMessages(), timings);
const toolEntries = transcript.filter((entry) => entry.role === "tool");
const resultEntries = transcript.filter(
(entry) => entry.role === "toolResult",
);
for (const entries of [toolEntries, resultEntries]) {
assert.deepEqual(
entries.map(({ toolCallId, startedAt, finishedAt, durationMs }) => ({
toolCallId,
startedAt,
finishedAt,
durationMs,
})),
[
{
toolCallId: "call-a",
startedAt: 1_000,
finishedAt: 1_030,
durationMs: 30,
},
{
toolCallId: "call-b",
startedAt: 1_002,
finishedAt: 1_012,
durationMs: 10,
},
],
);
}
});
test("in-flight aborted tool calls retain start timing without completion", () => {
const timings = new Map<string, ToolExecutionTiming>();
recordToolExecutionTiming(
timings,
{
type: "tool_execution_start",
toolCallId: "call-a",
toolName: "first",
args: { value: 1 },
},
2_000,
);
const transcript = transcriptFromMessages(
parallelToolMessages().slice(0, 2),
timings,
);
const first = transcript.find((entry) => entry.toolCallId === "call-a");
assert.equal(first?.startedAt, 2_000);
assert.equal(first?.finishedAt, undefined);
assert.equal(first?.durationMs, undefined);
assert.equal(
transcript.some((entry) => entry.role === "toolResult"),
false,
);
});
test("first-response watchdog aborts a silent provider request", async () => {
let aborted = false;
const watchdog = createFirstResponseWatchdog(
async () => {
aborted = true;
},
{ timeoutMs: 10, model: "fixture-model" },
);
await assert.rejects(
watchdog.waitFor(new Promise<never>(() => {})),
/no assistant response event for fixture-model within 10 ms.*stalled/i,
);
assert.equal(aborted, true);
});
test("first assistant response disarms the watchdog without limiting the run", async () => {
const watchdog = createFirstResponseWatchdog(
async () => {
throw new Error("watchdog should have been disarmed");
},
{ timeoutMs: 10 },
);
watchdog.markResponse();
const result = await watchdog.waitFor(
new Promise<string>((resolve) => setTimeout(() => resolve("done"), 20)),
);
assert.equal(result, "done");
});
test("workflow children guard structured, normal, and dynamically registered tools", async () => {
const structuredResult = {
content: [{ type: "text" as const, text: "recorded" }],
details: { value: "fixture" },
terminate: true,
};
const structured = {
name: "structured_output",
label: "Structured Output",
description: "fixture",
parameters: Type.Object({}),
async execute() {
return structuredResult;
},
} satisfies ToolDefinition;
const definitions = new Map<string, ToolDefinition>([
[structured.name, structured],
]);
let listener: AgentSessionEventListener | undefined;
const session = {
getAllTools: () => [...definitions.keys()].map((name) => ({ name })),
getToolDefinition: (name: string) => definitions.get(name),
subscribe(next: AgentSessionEventListener) {
listener = next;
return () => {
listener = undefined;
};
},
};
const unsubscribe = guardWorkflowChildTools(session, 10);
assert.equal(await structured.execute(), structuredResult);
let dynamicSignal: AbortSignal | undefined;
const dynamic = {
name: "dynamic_fixture",
label: "Dynamic Fixture",
description: "fixture",
parameters: Type.Object({}),
async execute(
_toolCallId: string,
_params: Record<string, never>,
signal?: AbortSignal,
) {
dynamicSignal = signal;
return new Promise<never>(() => {});
},
} satisfies ToolDefinition;
const originalDynamicExecute = dynamic.execute;
definitions.set(dynamic.name, dynamic);
listener?.({ type: "agent_start" });
assert.notEqual(dynamic.execute, originalDynamicExecute);
await assert.rejects(
dynamic.execute("fixture", {}, undefined),
/Tool call "dynamic_fixture" timed out after 10 ms\./,
);
assert.equal(dynamicSignal?.aborted, true);
unsubscribe();
});
+706
View File
@@ -0,0 +1,706 @@
/**
* Workflow subagent runner.
*
* Each `agent()` call in a workflow script becomes one isolated in-process
* AgentSession created here: in-memory session, normal trust-aware resources
* and extensions, recursive orchestration/user-prompt tools denied, and an
* optional one-shot `structured_output` tool when a schema is supplied.
*
* `runAgent()` never throws: every failure mode (session creation, provider
* errors, aborts, missing structured output) settles into an `AgentOutcome`.
*/
import {
createAgentSession,
type DefaultResourceLoader,
defineTool,
SessionManager,
type SettingsManager,
type AgentSession,
type AgentSessionEvent,
type AgentSessionEventListener,
type ExtensionAPI,
type ExtensionContext,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type, type TSchema } from "typebox";
import {
bindChildSessionExtensions,
childToolPolicy,
createChildResources,
shutdownAndDisposeChildSession,
waitForChildOperation,
} from "../shared/child-session.ts";
import { createToolCallTimeoutGuard } from "../shared/tool-call-timeout.ts";
import { emptyUsage, type AgentUsage, type TranscriptEntry } from "./model.ts";
import {
buildWorkflowAgentPrompt,
STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
} from "./prompt.ts";
import { safeStringify, truncateUtf8 } from "./serialization.ts";
const AGENT_OUTPUT_MAX_BYTES = 64 * 1024;
export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
const TRANSCRIPT_ENTRY_MAX_BYTES = 16 * 1024;
const TRANSCRIPT_TOTAL_MAX_BYTES = 256 * 1024;
const TRANSCRIPT_MAX_ENTRIES = 200;
export type WorkflowModel = NonNullable<ExtensionContext["model"]>;
export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
type AgentMessage = AgentSession["messages"][number];
type ToolTimingEvent = Extract<
AgentSessionEvent,
{ type: "tool_execution_start" | "tool_execution_end" }
>;
export interface ToolExecutionTiming {
startedAt?: number;
finishedAt?: number;
durationMs?: number;
}
export interface AgentOutcome {
ok: boolean;
/** Final assistant text (may be empty when only structured output was produced). */
output: string;
/** Captured structured_output payload when a schema was supplied. */
structured?: unknown;
error?: string;
aborted: boolean;
usage: AgentUsage;
provider?: string;
model?: string;
thinkingLevel?: ThinkingLevel;
childSessionId?: string;
contextWindow?: number;
transcript: TranscriptEntry[];
}
export interface AgentProgress {
preview: string;
usage: AgentUsage;
model?: string;
contextWindow?: number;
transcript: TranscriptEntry[];
}
export interface RunAgentOptions {
prompt: string;
schema?: unknown;
model?: WorkflowModel;
thinkingLevel?: ThinkingLevel;
cwd: string;
loader: DefaultResourceLoader;
settingsManager: SettingsManager;
modelRegistry: ExtensionContext["modelRegistry"];
signal?: AbortSignal;
tools?: string[];
customTools?: ToolDefinition<any, any, any>[];
onProgress?: (progress: AgentProgress) => void;
onSessionCreated?: (metadata: {
childSessionId: string;
provider?: string;
model?: string;
thinkingLevel: ThinkingLevel;
}) => void;
/** Test-only override for the per-tool execution timeout. */
toolCallTimeoutMs?: number;
/** Test-only override for the first assistant response-event timeout. */
firstResponseTimeoutMs?: number;
}
/** Build a fresh extension runtime for each concurrent workflow child. */
export function createWorkflowResources(
cwd: string,
variant: "plain" | "structured",
projectTrusted: boolean,
) {
return createChildResources({
cwd,
projectTrusted,
...(variant === "structured"
? { appendSystemPrompt: [STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION] }
: {}),
});
}
interface WorkflowToolSession {
getAllTools(): Array<{ name: string }>;
getToolDefinition(name: string): ToolDefinition | undefined;
subscribe(listener: AgentSessionEventListener): () => void;
}
/** Guard current tools and tools registered by extensions at later agent starts. */
export function guardWorkflowChildTools(
session: WorkflowToolSession,
timeoutMs?: number,
) {
const guard = createToolCallTimeoutGuard(timeoutMs);
guard.apply(session);
return session.subscribe((event) => {
if (event.type === "agent_start") guard.apply(session);
});
}
function isJsonSchema(value: unknown): value is TSchema {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const seen = new WeakSet<object>();
let nodes = 0;
const validate = (current: unknown, depth: number): boolean => {
if (++nodes > 10_000 || depth > 24) return false;
if (
current === null ||
typeof current === "string" ||
typeof current === "boolean"
) {
return true;
}
if (typeof current === "number") return Number.isFinite(current);
if (Array.isArray(current)) {
return current.every((item) => validate(item, depth + 1));
}
if (typeof current !== "object") return false;
if (seen.has(current)) return false;
seen.add(current);
return Object.keys(current).every((key) => {
if (key === "__proto__" || key === "constructor" || key === "prototype") {
return false;
}
return validate((current as Record<string, unknown>)[key], depth + 1);
});
};
return validate(value, 0);
}
/** Preserve the caller's full JSON Schema instead of lossy keyword conversion. */
function jsonSchemaToTypebox(schema: unknown): TSchema {
if (!isJsonSchema(schema)) {
throw new Error("structured output schema must be a bounded JSON object");
}
return Type.Unsafe(schema);
}
/**
* One-shot terminating tool injected when a schema is supplied: the subagent
* calls it as its final action and we capture the validated object.
*/
function makeStructuredOutputTool(
schema: unknown,
capture: (value: unknown) => void,
): ToolDefinition {
return defineTool({
name: "structured_output",
label: "Structured Output",
description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
parameters: jsonSchemaToTypebox(schema),
async execute(_toolCallId, params) {
capture(params);
return {
content: [{ type: "text", text: "Recorded structured result." }],
details: params,
terminate: true,
};
},
});
}
function finalOutput(messages: AgentMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const text = msg.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (text) return text;
}
return "";
}
function safeJson(value: unknown): string {
return safeStringify(value, {
maxBytes: TRANSCRIPT_ENTRY_MAX_BYTES,
maxDepth: 12,
maxNodes: 2_000,
});
}
/** Record lifecycle timings without inferring completion from message timestamps. */
export function recordToolExecutionTiming(
timings: Map<string, ToolExecutionTiming>,
event: ToolTimingEvent,
observedAt = Date.now(),
) {
const previous = timings.get(event.toolCallId);
if (event.type === "tool_execution_start") {
if (previous?.startedAt !== undefined) return;
timings.set(event.toolCallId, { ...previous, startedAt: observedAt });
return;
}
if (previous?.finishedAt !== undefined) return;
const durationMs =
previous?.startedAt === undefined
? undefined
: Math.max(0, observedAt - previous.startedAt);
timings.set(event.toolCallId, {
...previous,
finishedAt: observedAt,
...(durationMs === undefined ? {} : { durationMs }),
});
}
function toolMetadata(
toolCallId: string,
timings: ReadonlyMap<string, ToolExecutionTiming>,
) {
const timing = timings.get(toolCallId);
return {
toolCallId: truncateUtf8(toolCallId, 1024),
...(timing?.startedAt === undefined ? {} : { startedAt: timing.startedAt }),
...(timing?.finishedAt === undefined
? {}
: { finishedAt: timing.finishedAt }),
...(timing?.durationMs === undefined
? {}
: { durationMs: timing.durationMs }),
};
}
/** Convert pi messages into a compact, serializable transcript for the UI. */
export function transcriptFromMessages(
messages: AgentMessage[],
toolTimings: ReadonlyMap<string, ToolExecutionTiming> = new Map(),
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
for (const message of messages) {
if (message.role === "user") {
const text =
typeof message.content === "string"
? message.content
: message.content
.map((part) =>
part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
)
.join("\n");
if (text.trim()) {
entries.push({ role: "user", text, timestamp: message.timestamp });
}
continue;
}
if (message.role === "assistant") {
for (const part of message.content) {
if (part.type === "text" && part.text.trim()) {
entries.push({
role: "assistant",
text: part.text,
timestamp: message.timestamp,
});
} else if (part.type === "thinking" && part.thinking.trim()) {
entries.push({
role: "thinking",
text: part.thinking,
timestamp: message.timestamp,
});
} else if (part.type === "toolCall") {
entries.push({
role: "tool",
name: part.name,
text: safeJson(part.arguments),
timestamp: message.timestamp,
...toolMetadata(part.id, toolTimings),
});
}
}
continue;
}
if (message.role !== "toolResult") continue;
const text = message.content
.map((part) =>
part.type === "text" ? part.text : `[image: ${part.mimeType}]`,
)
.join("\n");
entries.push({
role: "toolResult",
name: message.toolName,
text,
isError: message.isError,
timestamp: message.timestamp,
...toolMetadata(message.toolCallId, toolTimings),
});
}
const selected =
entries.length <= TRANSCRIPT_MAX_ENTRIES
? entries
: [entries[0], ...entries.slice(-(TRANSCRIPT_MAX_ENTRIES - 1))];
const bounded: TranscriptEntry[] = [];
let totalBytes = 0;
for (const entry of selected) {
const remaining = TRANSCRIPT_TOTAL_MAX_BYTES - totalBytes;
if (remaining <= 0) break;
const text = truncateUtf8(
entry.text,
Math.min(TRANSCRIPT_ENTRY_MAX_BYTES, remaining),
);
totalBytes += Buffer.byteLength(text, "utf8");
bounded.push({
...entry,
text:
text === entry.text ? text : `${text}\n[transcript entry truncated]`,
});
}
if (bounded.length < entries.length) {
bounded.push({
role: "toolResult",
name: "transcript",
text: `[transcript truncated: retained ${bounded.length} of ${entries.length} entries]`,
});
}
return bounded;
}
function computeUsage(messages: AgentMessage[]): AgentUsage {
const usage = emptyUsage();
for (const msg of messages) {
if (msg.role !== "assistant") continue;
usage.turns++;
const u = msg.usage;
if (!u) continue;
usage.input += u.input || 0;
usage.output += u.output || 0;
usage.cacheRead += u.cacheRead || 0;
usage.cacheWrite += u.cacheWrite || 0;
usage.cost += u.cost?.total || 0;
}
return usage;
}
function errorText(error: unknown): string {
return (error instanceof Error ? error.message : String(error)).slice(
0,
16 * 1024,
);
}
function formatTimeout(timeoutMs: number) {
return timeoutMs % 1_000 === 0
? `${timeoutMs / 1_000} seconds`
: `${timeoutMs} ms`;
}
/** Abort a provider call that opens but never emits its first assistant event. */
export function createFirstResponseWatchdog(
onTimeout: () => Promise<unknown>,
options: { timeoutMs?: number; model?: string } = {},
) {
const timeoutMs = options.timeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
timer = undefined;
const model = options.model ? ` for ${options.model}` : "";
reject(
new Error(
`Agent received no assistant response event${model} within ${formatTimeout(timeoutMs)}; the provider request may be stalled. Retry the workflow.`,
),
);
void onTimeout().catch(() => {});
}, timeoutMs);
timer.unref?.();
});
const cancel = () => {
if (timer) clearTimeout(timer);
timer = undefined;
};
return {
markResponse: cancel,
async waitFor<T>(operation: Promise<T>) {
try {
return await Promise.race([operation, timeout]);
} finally {
cancel();
}
},
};
}
function isAssistantResponseEvent(event: AgentSessionEvent) {
return (
(event.type === "message_start" ||
event.type === "message_update" ||
event.type === "message_end") &&
event.message.role === "assistant"
);
}
export async function runAgent(
options: RunAgentOptions,
): Promise<AgentOutcome> {
let structured: unknown;
let customTools: ToolDefinition<any, any, any>[];
let session: AgentSession | undefined;
let unsubscribeToolTimeout: (() => void) | undefined;
try {
customTools = [
...(options.customTools ?? []),
...(options.schema === undefined
? []
: [
makeStructuredOutputTool(options.schema, (value) => {
structured = value;
}),
]),
];
const customToolNames = customTools.map((tool) => tool.name);
if (new Set(customToolNames).size !== customToolNames.length) {
throw new Error("Workflow child custom tool names must be unique");
}
({ session } = await createAgentSession({
cwd: options.cwd,
...(options.model ? { model: options.model } : {}),
...(options.thinkingLevel
? { thinkingLevel: options.thinkingLevel }
: {}),
resourceLoader: options.loader,
settingsManager: options.settingsManager,
sessionManager: SessionManager.inMemory(options.cwd),
...(customTools.length > 0 ? { customTools } : {}),
...(options.tools ? { tools: options.tools } : {}),
...childToolPolicy(),
}));
await bindChildSessionExtensions(session);
unsubscribeToolTimeout = guardWorkflowChildTools(
session,
options.toolCallTimeoutMs,
);
} catch (error) {
unsubscribeToolTimeout?.();
if (session) await shutdownAndDisposeChildSession(session);
return {
ok: false,
output: "",
error: `Failed to create agent session: ${errorText(error)}`,
aborted: false,
usage: emptyUsage(),
provider: options.model?.provider,
model: options.model?.id,
thinkingLevel: options.thinkingLevel,
contextWindow: options.model?.contextWindow,
transcript: [],
};
}
const childSession = session;
const childSessionId = childSession.sessionManager.getSessionId();
const thinkingLevel = childSession.thinkingLevel;
let usage = emptyUsage();
let provider = childSession.model?.provider ?? options.model?.provider;
let modelId = childSession.model?.id ?? options.model?.id;
try {
options.onSessionCreated?.({
childSessionId,
provider,
model: modelId,
thinkingLevel,
});
} catch {
// Metadata observers must not disrupt or leak a successfully created child.
}
let contextWindow = childSession.model?.contextWindow;
let stopReason: string | undefined;
let errorMessage: string | undefined;
const toolTimings = new Map<string, ToolExecutionTiming>();
const sync = () => {
const messages = childSession.messages;
usage = computeUsage(messages);
const sessionModel = childSession.model;
provider = sessionModel?.provider ?? provider;
modelId = sessionModel?.id ?? modelId;
contextWindow = sessionModel?.contextWindow ?? contextWindow;
const context = childSession.getContextUsage();
if (
typeof context?.tokens === "number" &&
Number.isFinite(context.tokens) &&
context.tokens >= 0
) {
usage.contextTokens = context.tokens;
}
if (
typeof context?.contextWindow === "number" &&
Number.isFinite(context.contextWindow) &&
context.contextWindow > 0
) {
contextWindow = context.contextWindow;
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
// Some gateways report a concrete fallback model. Prefer its registry
// metadata when available so capacity tracks the model that served the
// latest response rather than a hardcoded/configured guess.
const responseMatchesSession =
!sessionModel ||
(msg.provider === sessionModel.provider &&
msg.model === sessionModel.id);
provider = msg.provider;
const reportedId = msg.responseModel ?? msg.model;
const reportedModel = responseMatchesSession
? options.modelRegistry.find(msg.provider, reportedId)
: undefined;
if (reportedModel) {
modelId = reportedModel.id;
contextWindow = reportedModel.contextWindow;
}
if (msg.stopReason) stopReason = msg.stopReason;
if (msg.errorMessage) errorMessage = msg.errorMessage;
break;
}
};
let markFirstResponse = () => {};
const unsubscribe = childSession.subscribe((event) => {
if (isAssistantResponseEvent(event)) markFirstResponse();
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end"
) {
recordToolExecutionTiming(toolTimings, event);
} else if (
event.type !== "message_end" &&
event.type !== "compaction_end"
) {
return;
}
sync();
options.onProgress?.({
preview: finalOutput(childSession.messages),
usage,
model: modelId,
contextWindow,
transcript: transcriptFromMessages(childSession.messages, toolTimings),
});
});
let aborted = false;
let abortPromise: Promise<void> | undefined;
let resolveCancellation = () => {};
const cancellation = new Promise<void>((resolve) => {
resolveCancellation = resolve;
});
const onAbort = () => {
aborted = true;
resolveCancellation();
abortPromise ??= waitForChildOperation(childSession.abort());
};
if (options.signal) {
if (options.signal.aborted) onAbort();
else options.signal.addEventListener("abort", onAbort, { once: true });
}
let output = "";
let transcript: TranscriptEntry[] = [];
try {
if (!aborted) {
const watchdog = createFirstResponseWatchdog(() => childSession.abort(), {
timeoutMs: options.firstResponseTimeoutMs,
model: modelId,
});
markFirstResponse = watchdog.markResponse;
await Promise.race([
watchdog.waitFor(
childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
),
cancellation,
]);
}
} catch (error) {
errorMessage = errorMessage ?? errorText(error);
stopReason = stopReason ?? "error";
} finally {
options.signal?.removeEventListener("abort", onAbort);
if (abortPromise) await abortPromise;
unsubscribe();
unsubscribeToolTimeout?.();
sync();
output = truncateUtf8(
finalOutput(childSession.messages),
AGENT_OUTPUT_MAX_BYTES,
);
transcript = transcriptFromMessages(childSession.messages, toolTimings);
await shutdownAndDisposeChildSession(childSession);
}
if (aborted || stopReason === "aborted") {
return {
ok: false,
output,
structured,
error: "Agent was aborted",
aborted: true,
usage,
provider,
model: modelId,
thinkingLevel,
childSessionId,
contextWindow,
transcript,
};
}
const failed = stopReason === "error" || errorMessage !== undefined;
if (failed) {
return {
ok: false,
output,
structured,
error: errorMessage ?? "Agent failed",
aborted: false,
usage,
provider,
model: modelId,
thinkingLevel,
childSessionId,
contextWindow,
transcript,
};
}
if (options.schema !== undefined && structured === undefined) {
return {
ok: false,
output,
error:
"Agent finished without calling structured_output; no structured result matching the schema was produced.",
aborted: false,
usage,
provider,
model: modelId,
thinkingLevel,
childSessionId,
contextWindow,
transcript,
};
}
return {
ok: true,
output,
structured,
aborted: false,
usage,
provider,
model: modelId,
thinkingLevel,
childSessionId,
contextWindow,
transcript,
};
}
@@ -0,0 +1,274 @@
"use strict";
// This file is launched by sandbox.ts in Node permission mode. It deliberately
// has no filesystem/network/child-process permissions and receives workflow
// source only over a validated IPC channel.
const vm = require("node:vm");
const sendIpc =
typeof process.send === "function" ? process.send.bind(process) : undefined;
// If a future V8 escape exposes `process`, remove the convenient bridges to
// builtins, native bindings, parent signalling, and addons before any workflow
// source is compiled. The parent still enforces the authenticated IPC protocol.
for (const capability of [
"getBuiltinModule",
"binding",
"_linkedBinding",
"dlopen",
"kill",
"abort",
"send",
]) {
try {
Object.defineProperty(process, capability, {
value: undefined,
writable: false,
configurable: false,
});
} catch {
// The VM boundary and permission mode remain mandatory controls.
}
}
const BOOTSTRAP = String.raw`
(function bootstrapWorkflowApi() {
"use strict";
const callHost = globalThis.__hostBridge;
delete globalThis.__hostBridge;
let nextRequestId = 0;
const unconsumed = new Set();
const inFlight = new Set();
function deepFreeze(value, depth = 0) {
if (!value || typeof value !== "object" || depth > 32 || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const key of Object.keys(value)) deepFreeze(value[key], depth + 1);
return value;
}
function requestAgent(promptValue, optionsValue = {}) {
const id = ++nextRequestId;
unconsumed.add(id);
let started;
const begin = () => {
unconsumed.delete(id);
if (!started) {
let payload;
try {
payload = JSON.stringify({
id,
prompt: typeof promptValue === "string" ? promptValue : String(promptValue ?? ""),
options: optionsValue && typeof optionsValue === "object" ? optionsValue : {},
});
} catch (error) {
started = Promise.reject(new Error("agent() arguments must be serializable: " + error.message));
return started;
}
inFlight.add(id);
started = callHost("agent", payload)
.then((json) => JSON.parse(json))
.finally(() => inFlight.delete(id));
}
return started;
};
return Object.freeze({
then(resolve, reject) {
return begin().then(resolve, reject);
},
catch(reject) {
return begin().catch(reject);
},
finally(callback) {
return begin().finally(callback);
},
get [Symbol.toStringTag]() {
return "Promise";
},
});
}
async function mapLimited(items, concurrency, invoke) {
const results = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (true) {
const index = next++;
if (index >= items.length) return;
results[index] = await invoke(items[index]);
}
});
await Promise.all(workers);
return results;
}
async function parallel(items, options = {}) {
if (!Array.isArray(items)) throw new Error("parallel() expects an array of zero-argument agent thunks");
const requested = options && typeof options.concurrency === "number"
? Math.floor(options.concurrency)
: 4;
if (!Number.isFinite(requested) || requested < 1) {
throw new Error("parallel(): concurrency must be a positive integer");
}
const concurrency = Math.min(4, requested);
return mapLimited(items, concurrency, (item) => {
if (typeof item !== "function") {
throw new Error("parallel() items must be zero-argument functions");
}
return item();
});
}
function phase(title) {
callHost("phase", JSON.stringify({ title: String(title) }));
}
const argsEnvelope = JSON.parse(globalThis.__argsJson);
const args = argsEnvelope.defined ? deepFreeze(argsEnvelope.value) : undefined;
delete globalThis.__argsJson;
const stringify = JSON.stringify;
function serializeResult(value) {
const seen = new WeakSet();
return stringify(value === undefined ? null : value, (_key, item) => {
if (typeof item === "bigint") return item.toString() + "n";
if (item && typeof item === "object") {
if (seen.has(item)) return "[circular]";
seen.add(item);
}
return item;
});
}
Object.defineProperties(globalThis, {
agent: { value: requestAgent, writable: false, configurable: false },
parallel: { value: parallel, writable: false, configurable: false },
phase: { value: phase, writable: false, configurable: false },
args: { value: args, writable: false, configurable: false },
__workflowCheck: {
value: Object.freeze(() => ({
unconsumed: unconsumed.size,
inFlight: inFlight.size,
})),
writable: false,
configurable: false,
},
__workflowSerialize: {
value: Object.freeze(serializeResult),
writable: false,
configurable: false,
},
});
})();
`;
let initialized = false;
let token;
const pendingAgents = new Map();
function send(message) {
sendIpc?.({ token, ...message });
}
function fail(error) {
const message = error instanceof Error ? error.message : String(error);
send({ kind: "error", error: message.slice(0, 16 * 1024) });
}
process.on("message", (message) => {
if (!message || typeof message !== "object") return;
if (!initialized) {
if (
message.kind !== "init" ||
typeof message.token !== "string" ||
typeof message.source !== "string" ||
typeof message.argsJson !== "string"
) {
process.exitCode = 1;
return;
}
initialized = true;
token = message.token;
run(message.source, message.argsJson);
return;
}
if (message.token !== token || message.kind !== "agentResult") return;
const pending = pendingAgents.get(message.id);
if (!pending) return;
pendingAgents.delete(message.id);
if (typeof message.resultJson === "string")
pending.resolve(message.resultJson);
else
pending.reject(
new Error(
typeof message.error === "string" ? message.error : "Agent IPC failed",
),
);
});
function run(source, argsJson) {
try {
const sandbox = Object.create(null);
sandbox.__argsJson = argsJson;
sandbox.__hostBridge = (kind, payloadJson) => {
if (kind === "phase") {
send({ kind: "phase", payloadJson });
return undefined;
}
if (kind !== "agent")
return Promise.reject(new Error("Unknown workflow operation"));
let id;
try {
id = JSON.parse(payloadJson).id;
} catch {
return Promise.reject(new Error("Invalid agent request"));
}
return new Promise((resolve, reject) => {
pendingAgents.set(id, { resolve, reject });
send({ kind: "agent", payloadJson });
});
};
const context = vm.createContext(sandbox, {
name: "workflow",
codeGeneration: { strings: false, wasm: false },
});
new vm.Script(BOOTSTRAP, {
filename: "workflow-bootstrap.js",
}).runInContext(context, { timeout: 1000 });
const workflow = vm.compileFunction(
`"use strict";\nreturn (async function workflow() {\n${source}\n})();`,
["agent", "parallel", "phase", "args"],
{ filename: "workflow-script.js", parsingContext: context },
);
context.__workflowBody = workflow;
const invoke = `
(() => {
const workflowBody = globalThis.__workflowBody;
delete globalThis.__workflowBody;
globalThis.__workflowPromise = Promise.resolve(
workflowBody(agent, parallel, phase, args),
).then(async (value) => {
await Promise.resolve();
const pending = __workflowCheck();
if (pending.unconsumed > 0) {
throw new Error("Workflow created " + pending.unconsumed + " unawaited agent() call(s)");
}
if (pending.inFlight > 0) {
throw new Error("Workflow returned before " + pending.inFlight + " agent call(s) settled");
}
return __workflowSerialize(value);
});
})();
`;
new vm.Script(invoke, { filename: "workflow-invoke.js" }).runInContext(
context,
{ timeout: 1000 },
);
Promise.resolve(context.__workflowPromise)
.then((resultJson) => {
if (typeof resultJson !== "string")
throw new Error("Workflow result was not serializable");
send({ kind: "result", resultJson });
})
.catch(fail);
} catch (error) {
fail(error);
}
}
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { runWorkflowSandbox } from "./sandbox.ts";
function run(
source: string,
overrides: Partial<Parameters<typeof runWorkflowSandbox>[0]> = {},
) {
const abort = new AbortController();
return runWorkflowSandbox({
source,
args: undefined,
cwd: process.cwd(),
signal: abort.signal,
onAgent: async (prompt) => ({ ok: true, output: `reply:${prompt}` }),
onPhase: () => {},
...overrides,
});
}
test("sandbox exposes only workflow capabilities and validates results", async () => {
const phases: string[] = [];
const result = await run(
`
phase("Gather");
const replies = await parallel([
() => agent("one"),
() => agent("two"),
], { concurrency: 99 });
return {
replies: replies.map((reply) => reply.output),
processType: typeof process,
requireType: typeof require,
fetchType: typeof fetch,
};
`,
{ onPhase: (title) => phases.push(title) },
);
assert.deepEqual(result, {
replies: ["reply:one", "reply:two"],
processType: "undefined",
requireType: "undefined",
fetchType: "undefined",
});
assert.deepEqual(phases, ["Gather"]);
});
test("sandbox result serialization handles cycles and bigint", async () => {
const result = await run(`
const value = { count: 7n };
value.self = value;
return value;
`);
assert.deepEqual(result, { count: "7n", self: "[circular]" });
});
test("sandbox rejects unawaited agent calls", async () => {
let calls = 0;
await assert.rejects(
run(`agent("orphan"); return "done";`, {
onAgent: async () => {
calls++;
return { ok: true, output: "unexpected" };
},
}),
/unawaited agent/,
);
assert.equal(calls, 0);
});
test("sandbox source cannot escape the host accounting wrapper", async () => {
let calls = 0;
await assert.rejects(
run(
`}), agent("orphan"), Promise.resolve("bypass"); (async function () {`,
{
onAgent: async () => {
calls++;
return { ok: true, output: "unexpected" };
},
},
),
/unawaited agent/,
);
assert.equal(calls, 0);
});
test("sandbox VM still rejects non-yielding synchronous code", async () => {
await assert.rejects(run(`while (true) {}`), /timed out/);
});
test("workflow agent invocations have no per-request wall timer", async () => {
let signalAborted = false;
const result = await run(`return (await agent("delayed")).output;`, {
onAgent: async (_prompt, _options, signal) => {
await new Promise((resolve) => setTimeout(resolve, 30));
signalAborted = signal.aborted;
return { ok: true, output: "completed" };
},
});
assert.equal(result, "completed");
assert.equal(signalAborted, false);
});
test("workflow cancellation aborts a pending agent request", async () => {
const controller = new AbortController();
let startedResolve: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
startedResolve = resolve;
});
let requestAborted = false;
const pending = run(`return await agent("pending");`, {
signal: controller.signal,
onAgent: async (_prompt, _options, signal) => {
startedResolve?.();
await new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
requestAborted = true;
resolve();
},
{ once: true },
);
});
return { ok: false, output: "", error: "Agent was aborted" };
},
});
await started;
controller.abort(new Error("cancel fixture"));
await assert.rejects(pending, /Workflow was aborted/);
assert.equal(requestAborted, true);
});
+299
View File
@@ -0,0 +1,299 @@
import { randomBytes } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { safeStringify, toSerializable } from "./serialization.ts";
const MAX_SOURCE_BYTES = 512 * 1024;
const MAX_ARGS_BYTES = 256 * 1024;
const MAX_RESULT_BYTES = 1024 * 1024;
const MAX_AGENT_MESSAGE_BYTES = 512 * 1024;
const MAX_AGENT_REQUESTS = 32;
export interface SandboxAgentOptions {
label?: unknown;
phase?: unknown;
schema?: unknown;
model?: unknown;
provider?: unknown;
effort?: unknown;
}
export interface SandboxAgentResult {
ok: boolean;
output: string;
structured?: unknown;
error?: string;
}
export interface RunWorkflowSandboxOptions {
source: string;
args: unknown;
cwd: string;
signal: AbortSignal;
onAgent: (
prompt: string,
options: SandboxAgentOptions,
signal: AbortSignal,
) => Promise<SandboxAgentResult>;
onPhase: (title: string) => void;
}
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorText(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function terminateChild(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
}, 1_000);
force.unref?.();
}
function sanitizeAgentOptions(value: unknown): SandboxAgentOptions {
if (!isRecord(value)) return {};
return {
...(value.label !== undefined ? { label: value.label } : {}),
...(value.phase !== undefined ? { phase: value.phase } : {}),
...(value.schema !== undefined ? { schema: value.schema } : {}),
...(value.model !== undefined ? { model: value.model } : {}),
...(value.provider !== undefined ? { provider: value.provider } : {}),
...(value.effort !== undefined ? { effort: value.effort } : {}),
};
}
/**
* Execute orchestration code in a separate, permission-restricted Node process.
* The child can only invoke the narrow agent/phase IPC protocol and is always
* terminated on completion, cancellation, or protocol failure. The workflow
* itself and its agent requests have no wall-clock deadline. Active requests
* are aborted only when the workflow is cancelled or the sandbox is cleaned up.
*/
export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
return Promise.reject(
new Error("This Node runtime cannot enforce workflow child permissions"),
);
}
if (byteLength(options.source) > MAX_SOURCE_BYTES) {
return Promise.reject(
new Error(`Workflow script exceeds the ${MAX_SOURCE_BYTES} byte limit`),
);
}
const argsJson = safeStringify(
{ defined: options.args !== undefined, value: options.args },
{ maxBytes: MAX_ARGS_BYTES, maxDepth: 16, maxNodes: 10_000 },
);
if (byteLength(argsJson) > MAX_ARGS_BYTES) {
return Promise.reject(new Error("Workflow args exceed the IPC limit"));
}
return new Promise<unknown>((resolve, reject) => {
const workerPath = fileURLToPath(
new URL("./sandbox-child.cjs", import.meta.url),
);
const child = spawn(
process.execPath,
[
"--permission",
`--allow-fs-read=${path.dirname(workerPath)}`,
"--max-old-space-size=128",
"--stack-size=2048",
workerPath,
],
{
cwd: options.cwd,
env: {
PATH: process.env.PATH ?? "",
NODE_NO_WARNINGS: "1",
},
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
const token = randomBytes(24).toString("hex");
const requestIds = new Set<number>();
const activeAgentRequests = new Map<number, AbortController>();
let requestCount = 0;
let finished = false;
const cleanup = () => {
for (const abortController of activeAgentRequests.values()) {
abortController.abort(new Error("Workflow stopped"));
}
activeAgentRequests.clear();
options.signal.removeEventListener("abort", onAbort);
child.removeAllListeners("message");
child.removeAllListeners("error");
child.removeAllListeners("exit");
terminateChild(child);
};
const finish = (error?: Error, value?: unknown) => {
if (finished) return;
finished = true;
cleanup();
if (error) reject(error);
else resolve(value);
};
const onAbort = () => finish(new Error("Workflow was aborted"));
options.signal.addEventListener("abort", onAbort, { once: true });
if (options.signal.aborted) {
onAbort();
return;
}
child.on("error", (error) => finish(error));
child.on("exit", (code, exitSignal) => {
if (!finished) {
finish(
new Error(
`Workflow sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
),
);
}
});
child.on("message", (raw: unknown) => {
if (
!isRecord(raw) ||
raw.token !== token ||
typeof raw.kind !== "string"
) {
finish(new Error("Workflow sandbox sent an invalid IPC message"));
return;
}
if (raw.kind === "phase") {
if (
typeof raw.payloadJson !== "string" ||
byteLength(raw.payloadJson) > 4096
) {
finish(new Error("Workflow sandbox sent an invalid phase update"));
return;
}
try {
const payload: unknown = JSON.parse(raw.payloadJson);
if (!isRecord(payload) || typeof payload.title !== "string") {
throw new Error("invalid title");
}
options.onPhase(payload.title.slice(0, 160));
} catch {
finish(new Error("Workflow sandbox sent an invalid phase update"));
}
return;
}
if (raw.kind === "agent") {
if (
typeof raw.payloadJson !== "string" ||
byteLength(raw.payloadJson) > MAX_AGENT_MESSAGE_BYTES
) {
finish(new Error("Workflow sandbox sent an oversized agent request"));
return;
}
let payload: unknown;
try {
payload = JSON.parse(raw.payloadJson);
} catch {
finish(new Error("Workflow sandbox sent malformed agent JSON"));
return;
}
if (
!isRecord(payload) ||
!Number.isSafeInteger(payload.id) ||
typeof payload.id !== "number" ||
payload.id < 1 ||
typeof payload.prompt !== "string" ||
payload.prompt.length > 100_000 ||
!isRecord(payload.options)
) {
finish(new Error("Workflow sandbox sent an invalid agent request"));
return;
}
if (requestIds.has(payload.id) || ++requestCount > MAX_AGENT_REQUESTS) {
finish(
new Error("Workflow sandbox exceeded its agent request budget"),
);
return;
}
requestIds.add(payload.id);
const id = payload.id;
const abortController = new AbortController();
const sendResult = (result: SandboxAgentResult) => {
if (!activeAgentRequests.delete(id)) return;
if (finished || !child.connected) return;
const normalized = toSerializable(result, {
maxDepth: 16,
maxNodes: 10_000,
maxStringBytes: 128 * 1024,
});
let resultJson = JSON.stringify(normalized);
if (byteLength(resultJson) > MAX_AGENT_MESSAGE_BYTES) {
resultJson = JSON.stringify({
ok: false,
output: "",
error: "Agent result exceeded the workflow IPC output limit",
});
}
child.send({ token, kind: "agentResult", id, resultJson });
};
activeAgentRequests.set(id, abortController);
void options
.onAgent(
payload.prompt,
sanitizeAgentOptions(payload.options),
abortController.signal,
)
.then(sendResult)
.catch((error) =>
sendResult({ ok: false, output: "", error: errorText(error) }),
);
return;
}
if (raw.kind === "result") {
if (
typeof raw.resultJson !== "string" ||
byteLength(raw.resultJson) > MAX_RESULT_BYTES
) {
finish(new Error("Workflow result exceeded the IPC limit"));
return;
}
try {
const normalized = toSerializable(JSON.parse(raw.resultJson));
finish(undefined, JSON.parse(JSON.stringify(normalized)));
} catch (error) {
finish(
new Error(`Workflow returned invalid JSON: ${errorText(error)}`),
);
}
return;
}
if (raw.kind === "error" && typeof raw.error === "string") {
finish(new Error(raw.error.slice(0, 16 * 1024)));
return;
}
finish(new Error("Workflow sandbox sent an unknown IPC message"));
});
child.send(
{
kind: "init",
token,
source: options.source,
argsJson,
},
(error) => {
if (error) finish(error);
},
);
});
}
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { safeStringify, writeFileAtomic } from "./serialization.ts";
test("safeStringify handles cycles, bigint, depth, and size", () => {
const value: Record<string, unknown> = {
bigint: 42n,
nested: { deeper: { deepest: true } },
large: "x".repeat(20_000),
};
value.self = value;
const text = safeStringify(value, {
maxBytes: 2_048,
maxDepth: 2,
maxStringBytes: 512,
});
assert.ok(Buffer.byteLength(text, "utf8") <= 2_048);
const parsed: unknown = JSON.parse(text);
assert.ok(parsed && typeof parsed === "object");
assert.match(text, /42n/);
assert.match(text, /circular/);
assert.match(text, /truncated/);
});
test("atomic writes leave complete readable content", () => {
const directory = mkdtempSync(join(tmpdir(), "workflow-test-"));
try {
const file = join(directory, "artifact.json");
writeFileAtomic(file, '{"value":1}');
writeFileAtomic(file, '{"value":2}');
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), { value: 2 });
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,162 @@
import * as fs from "node:fs";
import * as path from "node:path";
export interface SerializationOptions {
maxBytes?: number;
maxDepth?: number;
maxNodes?: number;
maxStringBytes?: number;
}
const DEFAULT_MAX_BYTES = 1024 * 1024;
const DEFAULT_MAX_DEPTH = 16;
const DEFAULT_MAX_NODES = 20_000;
const DEFAULT_MAX_STRING_BYTES = 64 * 1024;
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
export function truncateUtf8(value: string, maxBytes: number) {
if (maxBytes <= 0) return "";
if (byteLength(value) <= maxBytes) return value;
const buffer = Buffer.from(value, "utf8");
let end = Math.min(maxBytes, buffer.length);
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
return buffer.subarray(0, end).toString("utf8");
}
/**
* Normalize arbitrary values to inert JSON data. Cycles, bigint, non-finite
* numbers, deep trees, throwing properties, and very large strings are all
* represented explicitly instead of making artifact persistence fail.
*/
export function toSerializable(
value: unknown,
options: SerializationOptions = {},
): unknown {
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
const maxStringBytes = options.maxStringBytes ?? DEFAULT_MAX_STRING_BYTES;
const seen = new WeakMap<object, string>();
let nodes = 0;
const visit = (
current: unknown,
depth: number,
location: string,
): unknown => {
nodes++;
if (nodes > maxNodes) return "[truncated: node limit]";
if (depth > maxDepth) return "[truncated: depth limit]";
if (current === null || typeof current === "boolean") return current;
if (typeof current === "string") {
if (byteLength(current) <= maxStringBytes) return current;
return `${truncateUtf8(current, maxStringBytes)}\n[truncated: string limit]`;
}
if (typeof current === "number") {
return Number.isFinite(current)
? current
: `[number: ${String(current)}]`;
}
if (typeof current === "bigint") return `${current.toString()}n`;
if (typeof current === "undefined") return "[undefined]";
if (typeof current === "symbol")
return `[symbol: ${current.description ?? ""}]`;
if (typeof current === "function")
return `[function: ${current.name || "anonymous"}]`;
if (typeof current !== "object") return String(current);
const prior = seen.get(current);
if (prior) return `[circular: ${prior}]`;
seen.set(current, location);
if (Array.isArray(current)) {
return current.map((item, index) =>
visit(item, depth + 1, `${location}[${index}]`),
);
}
if (current instanceof Date) {
return Number.isNaN(current.getTime())
? "[date: invalid]"
: current.toISOString();
}
if (current instanceof Error) {
return {
name: current.name,
message: current.message,
...(current.stack
? { stack: truncateUtf8(current.stack, 16 * 1024) }
: {}),
};
}
const result: Record<string, unknown> = Object.create(null);
let keys: string[];
try {
keys = Object.keys(current);
} catch (error) {
return `[unreadable object: ${error instanceof Error ? error.message : String(error)}]`;
}
for (const key of keys) {
try {
result[key] = visit(
(current as Record<string, unknown>)[key],
depth + 1,
`${location}.${key}`,
);
} catch (error) {
result[key] =
`[unreadable property: ${error instanceof Error ? error.message : String(error)}]`;
}
}
return result;
};
return visit(value, 0, "$root");
}
/** Serialize to valid JSON no larger than the requested cap. */
export function safeStringify(
value: unknown,
options: SerializationOptions = {},
) {
const maxBytes = Math.max(256, options.maxBytes ?? DEFAULT_MAX_BYTES);
const normalized = toSerializable(value, options);
const serialized = JSON.stringify(normalized, null, 2) ?? "null";
if (byteLength(serialized) <= maxBytes) return serialized;
let previewBytes = Math.max(32, Math.floor(maxBytes / 3));
while (previewBytes > 0) {
const fallback = JSON.stringify(
{
truncated: true,
reason: `serialized value exceeded ${maxBytes} bytes`,
preview: truncateUtf8(serialized, previewBytes),
},
null,
2,
);
if (byteLength(fallback) <= maxBytes) return fallback;
previewBytes = Math.floor(previewBytes / 2);
}
return JSON.stringify({ truncated: true });
}
/** Durable same-directory replace: readers see either the old or new file. */
export function writeFileAtomic(filePath: string, content: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
fs.renameSync(temporary, filePath);
} catch (error) {
try {
fs.unlinkSync(temporary);
} catch {
// The original write error is more useful.
}
throw error;
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
# workflow
> **Historical reference:** This private legacy extension was superseded for new development by the model-invoked [`develop-workflow` skill](../../../skills/develop/develop-workflow/SKILL.md) under [ADR 0006](../../../docs/adr/0006-skill-led-development-workflow-04SEP2026.md).
Keep this workspace private, unpublished, and unexposed during the skill pilot. It is historical implementation evidence, not the skill implementation or a prerequisite for skill development. Archive or remove it only in the separately approved post-pilot cleanup.
@@ -0,0 +1,20 @@
---
name: feature-designer
description: Turns discovery evidence and user decisions into an actionable implementation plan.
roles: [design]
tools: [read, grep, find, ls]
selection:
stages: [design]
anyTags: []
allTags: []
pathGlobs: []
keywords: []
---
# Feature designer
Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible.
Emit exactly one eligible `implement` assignment, one eligible `verify` assignment, and one eligible independent `review` assignment. For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment whose owned paths isolate prototype artifacts from production implementation ownership. The prototype assignment is required even when the plan prose already describes prototypes. For `none` or `minor` UI impact, emit no prototype assignment.
The plan is complete only when every required assignment is present and an implementer can execute it without inventing missing contracts or crossing another assignment's ownership.
@@ -0,0 +1,16 @@
---
name: feature-implementer
description: Implements one approved assignment within declared file ownership.
roles: [implement]
tools: [read, grep, find, ls, edit, write]
selection:
stages: [implement]
anyTags: []
allTags: []
pathGlobs: []
keywords: []
---
# Feature implementer
Read the approved plan and implement only the assigned behavior within the declared owned paths. Follow existing project patterns, keep the change focused, and add tests for changed behavior. Report modified files, assumptions, and commands the host should run. The assignment is complete when the owned change satisfies its acceptance criteria without modifying files or Git state outside the assignment contract.
@@ -0,0 +1,16 @@
---
name: feature-verifier
description: Verifies approved behavior with focused tests, static checks, builds, and required browser evidence.
roles: [verify]
tools: [read, grep, find, ls]
selection:
stages: [verify]
anyTags: []
allTags: []
pathGlobs: []
keywords: []
---
# Feature verifier
Use `workflow_plan` repeatedly until every character range in the approved plan has been read. Compare that plan with the implementation evidence and inspect every required result from the host-provided command path. Confirm focused behavioral tests, type checks, linting, builds, and browser validation when applicable. Report each check and its actual outcome. Verification is complete only when every acceptance criterion has passing evidence or a precise failure that routes back to remediation.
@@ -0,0 +1,16 @@
---
name: general-discovery
description: Maps the repository, relevant behavior, constraints, risks, and unresolved questions before planning.
roles: [discover]
tools: [read, grep, find, ls]
selection:
stages: [discover]
anyTags: []
allTags: []
pathGlobs: []
keywords: []
---
# General discovery
Inspect the request and repository before proposing a design. Return relevant files and symbols, current behavior, integration points, constraints, risks, precedents, objective UI-impact evidence, and questions that cannot be answered from the repository. Distinguish verified facts from inferences. Discovery is complete when every requested behavior has a concrete codebase anchor or an explicit unresolved question.
@@ -0,0 +1,16 @@
---
name: independent-reviewer
description: Reviews the completed diff independently for specification conformance and repository standards.
roles: [review]
tools: [read, grep, find, ls]
selection:
stages: [review]
anyTags: []
allTags: []
pathGlobs: []
keywords: []
---
# Independent reviewer
Review the complete diff, approved plan, decisions, prototype evidence when applicable, and verification results without modifying the repository. Use `workflow_plan` and `workflow_diff` repeatedly until every character range reported by each tool has been read. Assess specification conformance and repository standards as separate axes. Report only concrete findings with locations, evidence, severity, and remediation. Review is complete when every changed behavior and owned file has been checked and the verdict is explicitly approved or changes requested.
@@ -0,0 +1,16 @@
---
name: ui-prototyper
description: Produces structurally distinct browser-reviewable prototypes for non-trivial UI work.
roles: [prototype]
tools: [read, grep, find, ls, edit, write]
selection:
stages: [prototype]
anyTags: [frontend, ui, ux]
allTags: []
pathGlobs: []
keywords: []
---
# UI prototyper
Create the assigned throwaway prototype in the existing application context with realistic data and responsive behavior. Make its structure meaningfully different from the other candidates, document its rationale, and provide a route and launch instructions for browser review. Keep prototype-only behavior isolated and easy to remove. The candidate is complete when its unique path, route, rationale, and responsive checks are recorded. Use the browser to validate the exact candidate route at mobile (at most 480px wide) and desktop (at least 1024px wide) viewports. Save each screenshot inside the candidate-owned path and write a JSON report beside it with `{ "route": <exact route>, "result": "passed", "viewport": { "width": <number>, "height": <number> }, "screenshotPath": <repository-relative path> }`. Return the repository-relative JSON report paths in `browserEvidence`; self-reported prose is not evidence.
@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { loadWorkflowConfig } from "./config.ts";
import { DEFAULT_WORKFLOW_CONFIG, MAX_CONCURRENCY } from "./domain.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-config-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function writeJson(file: string, value: unknown) {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, `${JSON.stringify(value)}\n`);
}
test("configuration defaults to concurrency 8 with immutable ceiling 32", async () => {
await withTempDir(async (directory) => {
const config = await loadWorkflowConfig({
cwd: directory,
agentDir: path.join(directory, "agent"),
projectTrusted: false,
});
assert.deepEqual(config, DEFAULT_WORKFLOW_CONFIG);
assert.equal(config.concurrency.ceiling, MAX_CONCURRENCY);
assert.deepEqual(config.standardAgents, {
enabled: true,
roles: {
scout: ["discover"],
worker: ["implement"],
},
});
});
});
test("a lowered ceiling clamps an inherited default", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { ceiling: 4 },
});
const globalOnly = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: false,
});
assert.deepEqual(globalOnly.concurrency, { default: 4, ceiling: 4 });
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { default: 6, ceiling: 16 },
});
await writeJson(path.join(directory, ".pi", "workflow.json"), {
concurrency: { ceiling: 3 },
});
const project = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: true,
});
assert.deepEqual(project.concurrency, { default: 3, ceiling: 3 });
});
});
test("trusted project configuration layers over global configuration and may lower limits", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { default: 6, ceiling: 16 },
maxAssignmentsPerRun: 64,
analysisTools: ["read", "grep", "find"],
worktrees: { root: ".worktrees/global" },
});
await writeJson(path.join(directory, ".pi", "workflow.json"), {
concurrency: { default: 4, ceiling: 8 },
maxReviewCycles: 5,
analysisTools: ["read", "find", "ls"],
worktrees: { root: ".worktrees/project" },
});
const config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: true,
invocationConcurrency: 7,
});
assert.deepEqual(config.concurrency, { default: 7, ceiling: 8 });
assert.equal(config.maxAssignmentsPerRun, 64);
assert.equal(config.maxReviewCycles, 5);
assert.deepEqual(config.analysisTools, ["read", "find"]);
assert.equal(config.worktrees.root, path.normalize(".worktrees/project"));
});
});
test("untrusted project configuration is never applied", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(directory, ".pi", "workflow.json"), {
concurrency: { default: 1, ceiling: 1 },
unknownField: true,
});
const config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: false,
});
assert.deepEqual(config.concurrency, { default: 8, ceiling: 32 });
});
});
test("trusted configuration can replace or disable standard-agent role overlays", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
standardAgents: {
roles: {
"codebase-analyzer": ["discover"],
worker: ["implement", "verify"],
},
},
});
let config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: false,
});
assert.deepEqual(config.standardAgents.roles, {
"codebase-analyzer": ["discover"],
worker: ["implement", "verify"],
});
await writeJson(path.join(directory, ".pi", "workflow.json"), {
standardAgents: { enabled: false },
});
config = await loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: true,
});
assert.equal(config.standardAgents.enabled, false);
});
});
test("invalid standard-agent names and roles fail before a run starts", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
standardAgents: { roles: { Worker: ["implement"] } },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/lowercase kebab case/,
);
await writeJson(path.join(agentDir, "workflow.json"), {
standardAgents: { roles: { worker: ["remediate"] } },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/unsupported role remediate/,
);
});
});
test("configuration cannot raise ceilings or activate unaudited tools", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { ceiling: 33 },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/between 1 and 32/,
);
await writeJson(path.join(agentDir, "workflow.json"), {
analysisTools: ["read", "bash"],
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/unsupported tools: bash/,
);
});
});
test("project configuration cannot raise a lowered global ceiling", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { default: 4, ceiling: 8 },
});
await writeJson(path.join(directory, ".pi", "workflow.json"), {
concurrency: { ceiling: 16 },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: true }),
/cannot raise the current ceiling/,
);
});
});
test("invalid numeric and path values fail before a run starts", async () => {
await withTempDir(async (directory) => {
const agentDir = path.join(directory, "agent");
await writeJson(path.join(agentDir, "workflow.json"), {
maxAssignmentsPerRun: 1.5,
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/must be an integer/,
);
await writeJson(path.join(agentDir, "workflow.json"), {
worktrees: { root: "../outside" },
});
await assert.rejects(
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
/must stay inside the repository/,
);
await writeJson(path.join(agentDir, "workflow.json"), {
concurrency: { default: 8, ceiling: 8 },
});
await assert.rejects(
loadWorkflowConfig({
cwd: directory,
agentDir,
projectTrusted: false,
invocationConcurrency: 9,
}),
/between 1 and 8/,
);
});
});
+321
View File
@@ -0,0 +1,321 @@
import { readFile } from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
ANALYSIS_TOOL_ALLOWLIST,
DEFAULT_WORKFLOW_CONFIG,
MAX_ASSIGNMENTS_PER_RUN,
MAX_CONCURRENCY,
MAX_REVIEW_CYCLES,
PROFILE_ROLES,
type ProfileRole,
type WorkflowConfig,
} from "./domain.ts";
const TOP_LEVEL_KEYS = new Set([
"concurrency",
"maxAssignmentsPerRun",
"maxReviewCycles",
"analysisTools",
"standardAgents",
"worktrees",
]);
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
const STANDARD_AGENT_KEYS = new Set(["enabled", "roles"]);
const WORKTREE_KEYS = new Set(["root"]);
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export interface LoadWorkflowConfigOptions {
cwd: string;
projectTrusted: boolean;
agentDir?: string;
invocationConcurrency?: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertKnownKeys(
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new Error(
`${label}: unknown field${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}`,
);
}
}
function integerInRange(
value: unknown,
label: string,
minimum: number,
maximum: number,
) {
if (!Number.isSafeInteger(value) || typeof value !== "number") {
throw new Error(`${label} must be an integer`);
}
if (value < minimum || value > maximum) {
throw new Error(`${label} must be between ${minimum} and ${maximum}`);
}
return value;
}
function stringArray(value: unknown, label: string) {
if (
!Array.isArray(value) ||
value.some((entry) => typeof entry !== "string")
) {
throw new Error(`${label} must be an array of strings`);
}
const normalized = value.map((entry) => entry.trim());
if (normalized.some((entry) => entry.length === 0)) {
throw new Error(`${label} cannot contain empty values`);
}
if (new Set(normalized).size !== normalized.length) {
throw new Error(`${label} cannot contain duplicate values`);
}
return normalized;
}
function worktreeRoot(value: unknown, label: string) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${label} must be a non-empty relative path`);
}
const normalized = path.normalize(value.trim());
if (
path.isAbsolute(normalized) ||
normalized === ".." ||
normalized.startsWith(`..${path.sep}`)
) {
throw new Error(`${label} must stay inside the repository`);
}
return normalized;
}
function cloneConfig(config: WorkflowConfig): WorkflowConfig {
return {
concurrency: { ...config.concurrency },
maxAssignmentsPerRun: config.maxAssignmentsPerRun,
maxReviewCycles: config.maxReviewCycles,
analysisTools: [...config.analysisTools],
standardAgents: {
enabled: config.standardAgents.enabled,
roles: Object.fromEntries(
Object.entries(config.standardAgents.roles).map(([name, roles]) => [
name,
[...roles],
]),
),
},
worktrees: { ...config.worktrees },
};
}
function applyConfigLayer(
current: WorkflowConfig,
value: unknown,
label: string,
): WorkflowConfig {
if (!isRecord(value)) {
throw new Error(`${label} must contain a JSON object`);
}
assertKnownKeys(value, TOP_LEVEL_KEYS, label);
const next = cloneConfig(current);
if (value.concurrency !== undefined) {
if (!isRecord(value.concurrency)) {
throw new Error(`${label}.concurrency must be an object`);
}
assertKnownKeys(
value.concurrency,
CONCURRENCY_KEYS,
`${label}.concurrency`,
);
if (value.concurrency.ceiling !== undefined) {
const ceiling = integerInRange(
value.concurrency.ceiling,
`${label}.concurrency.ceiling`,
1,
MAX_CONCURRENCY,
);
if (ceiling > current.concurrency.ceiling) {
throw new Error(
`${label}.concurrency.ceiling cannot raise the current ceiling`,
);
}
next.concurrency.ceiling = ceiling;
if (value.concurrency.default === undefined) {
next.concurrency.default = Math.min(
current.concurrency.default,
ceiling,
);
}
}
if (value.concurrency.default !== undefined) {
next.concurrency.default = integerInRange(
value.concurrency.default,
`${label}.concurrency.default`,
1,
next.concurrency.ceiling,
);
}
if (next.concurrency.default > next.concurrency.ceiling) {
throw new Error(
`${label}.concurrency.default exceeds the effective ceiling`,
);
}
}
if (value.maxAssignmentsPerRun !== undefined) {
next.maxAssignmentsPerRun = integerInRange(
value.maxAssignmentsPerRun,
`${label}.maxAssignmentsPerRun`,
1,
MAX_ASSIGNMENTS_PER_RUN,
);
}
if (value.maxReviewCycles !== undefined) {
next.maxReviewCycles = integerInRange(
value.maxReviewCycles,
`${label}.maxReviewCycles`,
1,
MAX_REVIEW_CYCLES,
);
}
if (value.analysisTools !== undefined) {
const requested = stringArray(
value.analysisTools,
`${label}.analysisTools`,
);
const invalid = requested.filter(
(tool) => !ANALYSIS_TOOL_ALLOWLIST.includes(tool as never),
);
if (invalid.length > 0) {
throw new Error(
`${label}.analysisTools contains unsupported tools: ${invalid.join(", ")}`,
);
}
next.analysisTools = current.analysisTools.filter((tool) =>
requested.includes(tool),
);
}
if (value.standardAgents !== undefined) {
if (!isRecord(value.standardAgents)) {
throw new Error(`${label}.standardAgents must be an object`);
}
assertKnownKeys(
value.standardAgents,
STANDARD_AGENT_KEYS,
`${label}.standardAgents`,
);
if (value.standardAgents.enabled !== undefined) {
if (typeof value.standardAgents.enabled !== "boolean") {
throw new Error(`${label}.standardAgents.enabled must be a boolean`);
}
next.standardAgents.enabled = value.standardAgents.enabled;
}
if (value.standardAgents.roles !== undefined) {
if (!isRecord(value.standardAgents.roles)) {
throw new Error(`${label}.standardAgents.roles must be an object`);
}
const roles: Record<string, ProfileRole[]> = {};
for (const [name, rawRoles] of Object.entries(
value.standardAgents.roles,
)) {
if (!PROFILE_NAME.test(name)) {
throw new Error(
`${label}.standardAgents.roles agent names must be lowercase kebab case`,
);
}
const requested = stringArray(
rawRoles,
`${label}.standardAgents.roles.${name}`,
);
if (requested.length === 0) {
throw new Error(
`${label}.standardAgents.roles.${name} must not be empty`,
);
}
const invalid = requested.filter(
(role) => !PROFILE_ROLES.includes(role as ProfileRole),
);
if (invalid.length > 0) {
throw new Error(
`${label}.standardAgents.roles.${name} contains unsupported role ${invalid.join(", ")}`,
);
}
roles[name] = requested as ProfileRole[];
}
next.standardAgents.roles = roles;
}
}
if (value.worktrees !== undefined) {
if (!isRecord(value.worktrees)) {
throw new Error(`${label}.worktrees must be an object`);
}
assertKnownKeys(value.worktrees, WORKTREE_KEYS, `${label}.worktrees`);
if (value.worktrees.root !== undefined) {
next.worktrees.root = worktreeRoot(
value.worktrees.root,
`${label}.worktrees.root`,
);
}
}
return next;
}
async function readOptionalJson(file: string) {
let contents: string;
try {
contents = await readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw new Error(`${file}: unable to read configuration`, { cause: error });
}
try {
return JSON.parse(contents) as unknown;
} catch (error) {
throw new Error(`${file}: invalid JSON`, { cause: error });
}
}
export async function loadWorkflowConfig(
options: LoadWorkflowConfigOptions,
): Promise<WorkflowConfig> {
const agentDir = options.agentDir ?? getAgentDir();
let config = cloneConfig(DEFAULT_WORKFLOW_CONFIG);
const globalFile = path.join(agentDir, "workflow.json");
const globalValue = await readOptionalJson(globalFile);
if (globalValue !== undefined) {
config = applyConfigLayer(config, globalValue, globalFile);
}
if (options.projectTrusted) {
const projectFile = path.join(options.cwd, ".pi", "workflow.json");
const projectValue = await readOptionalJson(projectFile);
if (projectValue !== undefined) {
config = applyConfigLayer(config, projectValue, projectFile);
}
}
if (options.invocationConcurrency !== undefined) {
config.concurrency.default = integerInRange(
options.invocationConcurrency,
"invocation concurrency",
1,
config.concurrency.ceiling,
);
}
return config;
}
@@ -0,0 +1,963 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { ImplementationContextState, WorkflowRun } from "./domain.ts";
import {
registerWorkflowContextTransitions,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import type { WorkflowCommand, WorkflowEngine, WorkflowView } from "./engine.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-context-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
type CommandHandler = (
args: string,
ctx: ExtensionCommandContext,
) => Promise<void>;
type ShutdownHandler = (
event: { reason: string; targetSessionFile?: string },
ctx: ExtensionContext,
) => Promise<void>;
type StartHandler = (
event: { reason: string; previousSessionFile?: string },
ctx: ExtensionContext,
) => Promise<void>;
function fakeApi() {
let command: CommandHandler | undefined;
let shutdown: ShutdownHandler | undefined;
let start: StartHandler | undefined;
const commandNames: string[] = [];
const userMessages: string[] = [];
const api = {
registerCommand(name: string, options: { handler: CommandHandler }) {
commandNames.push(name);
command = options.handler;
},
sendUserMessage(message: string) {
userMessages.push(message);
},
on(event: string, handler: ShutdownHandler | StartHandler) {
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
if (event === "session_start") start = handler as StartHandler;
},
} as unknown as ExtensionAPI;
return {
api,
commandNames,
userMessages,
command: () => {
assert.ok(command);
return command;
},
shutdown: () => {
assert.ok(shutdown);
return shutdown;
},
start: () => {
assert.ok(start);
return start;
},
};
}
function workflowView(
planPath: string,
revision: number,
context: ImplementationContextState,
): WorkflowView {
return {
runId: "run-1",
featureName: "example-feature",
request: "Build example feature",
revision,
status: context.status === "ready" ? "running" : "waiting",
background: false,
workspace: {
state: "ready",
sourceRoot: "/repo",
root: "/repo/.worktrees/run-1",
branch: "feature/example",
baseHead: "abc123",
},
openGate:
context.status === "ready"
? undefined
: {
id: context.gateId,
kind: "implementation-context",
status: "open",
revision: 10,
openedAt: 1,
},
planArtifact: {
path: planPath,
revision: 1,
digest: createHash("sha256").update(readFileSync(planPath)).digest("hex"),
},
implementationContext: structuredClone(context),
stages: [],
assignments: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
uiImpactEvidence: [],
affectedUiSurfaces: [],
};
}
function commandContext(options: {
sessionId: string;
sessionFile: string;
compact?: ExtensionCommandContext["compact"];
newSession?: ExtensionCommandContext["newSession"];
mode?: ExtensionCommandContext["mode"];
hasUI?: boolean;
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
select?: (title: string, options: string[]) => Promise<string | undefined>;
customResults?: unknown[];
onCustomRender?: (lines: string[]) => void;
}) {
const notifications: string[] = [];
const customResults = [...(options.customResults ?? [])];
const overlayTheme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = {
cwd: "/repo",
sessionManager: {
getSessionId: () => options.sessionId,
getSessionFile: () => options.sessionFile,
},
mode: options.mode ?? "print",
hasUI: options.hasUI ?? false,
ui: {
notify(message: string) {
notifications.push(message);
},
custom: async (
factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: typeof overlayTheme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
let completed: unknown;
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
overlayTheme,
{},
(value) => { completed = value; },
);
options.onCustomRender?.(component.render(100));
return customResults.length > 0 ? customResults.shift() : completed;
},
input: options.input ?? (async () => undefined),
select: options.select ?? (async () => undefined),
},
async waitForIdle() {},
compact: options.compact ?? (() => {}),
newSession:
options.newSession ??
(async () => ({ cancelled: false })) as ExtensionCommandContext["newSession"],
} as unknown as ExtensionCommandContext;
return { context, notifications };
}
test("/workflow routes non-continuation arguments to the dashboard", async () => {
const registered = fakeApi();
const opened: string[] = [];
registerWorkflowContextTransitions(
registered.api,
{
store: {
async listRuns() {
return [];
},
},
engineForContext: () => {
throw new Error("dashboard should not create an engine");
},
} as unknown as ContextTransitionRuntime,
async (args) => {
opened.push(args);
return {};
},
);
await registered.command()("run-1", commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
}).context);
assert.deepEqual(opened, ["run-1"]);
});
test("/workflow start routes the exact request through the assistant", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("start should route through the assistant");
},
} as unknown as ContextTransitionRuntime);
await registered.command()(
"start Build searchable workflow cards",
commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
}).context,
);
assert.equal(registered.userMessages.length, 1);
assert.match(registered.userMessages[0]!, /invoke workflow/);
assert.ok(
registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"),
);
});
test("the misspelled /worlflow alias is not registered", () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("command registration should not create an engine");
},
} as unknown as ContextTransitionRuntime);
assert.deepEqual(registered.commandNames, ["workflow"]);
});
test("/workflow start prompts in the TUI and requires a message otherwise", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("start should route through the assistant");
},
} as unknown as ContextTransitionRuntime);
let prompt = "";
await registered.command()(
"start",
commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
customResults: ["Add workflow lifecycle commands"],
onCustomRender: (lines) => { prompt = lines.join("\n"); },
}).context,
);
assert.match(prompt, /What do you want to start working on\?/);
assert.ok(
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
);
const fallback = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("start", fallback.context);
assert.ok(fallback.notifications.some((message) => message.includes("required")));
});
test("/workflow continue lists only valid runs and resume is an alias", async () => {
const validRun = (runId: string, activeParentSessionId = "session-1") =>
({
runId,
revision: 10,
status: "waiting",
request: `Request ${runId}`,
featureName: runId === "historical" ? "historical" : "shared-feature",
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
planArtifact: { path: "/plan.md", revision: 1, digest: "digest" },
implementationContext: {
gateId: `gate-${runId}`,
runRevision: 10,
planRevision: 1,
preparationId: `prep-${runId}`,
activeParentSessionId,
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
gates: [
{
id: `gate-${runId}`,
kind: "implementation-context",
status: "open",
revision: 10,
openedAt: 1,
},
],
assignments: [],
}) as unknown as WorkflowRun;
const renderedSelections: string[] = [];
const inspected: string[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
validRun("run-1"),
validRun("run-2"),
validRun("historical", "session-2"),
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
if (command.action === "inspect") {
inspected.push(command.runId);
return {
runId: command.runId,
revision: 1,
status: "running",
background: false,
workspace: { state: "ready", sourceRoot: "/repo" },
stages: [],
assignments: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
uiImpactEvidence: [],
affectedUiSurfaces: [],
} as WorkflowView;
}
throw new Error(`Unexpected command ${command.action}`);
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const interactive = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
});
await registered.command()("continue", interactive.context);
await registered.command()("resume", interactive.context);
assert.equal(renderedSelections.length, 2);
for (const rendered of renderedSelections) {
assert.match(rendered, /Continue Workflow Run/);
assert.match(rendered, /shared-feature · run-1/);
assert.match(rendered, /shared-feature · run-2/);
assert.doesNotMatch(rendered, /historical/);
}
await registered.command()("resume run-2", interactive.context);
assert.deepEqual(inspected, ["run-2"]);
});
test("/workflow continue without a run ID gives actionable non-UI guidance", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => {
throw new Error("non-UI fallback should not create an engine");
},
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("continue", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("/workflow continue <run-id>"),
),
);
});
test("/workflow continue reports the supplied run ID when inspection cannot load it", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () =>
({
async execute() {
throw new Error("not found");
},
}) as unknown as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("continue missing-run-id", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("Workflow Run missing-run-id could not be loaded"),
),
);
});
test("/workflow stop selects among active runs owned by the current session", async () => {
const run = (
runId: string,
status: WorkflowRun["status"],
activeParentSessionId: string,
) =>
({
runId,
revision: 4,
status,
request: `Request ${runId}`,
featureName: "duplicate-label",
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
}) as unknown as WorkflowRun;
const commands: WorkflowCommand[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
run("run-1", "running", "session-1"),
run("run-2", "waiting", "session-1"),
run("completed", "completed", "session-1"),
run("historical", "running", "session-2"),
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
commands.push(command);
if (command.action !== "inspect" && command.action !== "abort") {
throw new Error(`Unexpected command ${command.action}`);
}
return {
runId: command.runId,
revision: 4,
status: command.action === "abort" ? "aborted" : "waiting",
} as WorkflowView;
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
let choices = "";
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
mode: "tui",
hasUI: true,
customResults: [run("run-2", "waiting", "session-1")],
onCustomRender: (lines) => { choices = lines.join("\n"); },
});
await registered.command()("stop", command.context);
assert.match(choices, /Stop Workflow Run/);
assert.match(choices, /duplicate-label · run-1/);
assert.match(choices, /duplicate-label · run-2/);
assert.doesNotMatch(choices, /completed|historical/);
assert.deepEqual(
commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
),
["inspect:run-2", "abort:run-2"],
);
const abort = commands[1];
assert.match(
abort?.action === "abort" ? abort.idempotencyKey : "",
/^workflow-stop-run-2-4-/,
);
});
test("/workflow stop immediately stops the only active run", async () => {
const commands: WorkflowCommand[] = [];
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () => [
{
runId: "run-1",
revision: 2,
status: "running",
request: "Only active run",
lease: { activeParentSessionId: "session-1" },
} as unknown as WorkflowRun,
],
},
engineForContext: () =>
({
async execute(command: WorkflowCommand) {
commands.push(command);
if (command.action !== "inspect" && command.action !== "abort") {
throw new Error(`Unexpected command ${command.action}`);
}
return {
runId: command.runId,
revision: 2,
status: command.action === "abort" ? "aborted" : "running",
} as WorkflowView;
},
}) as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("stop", command.context);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "abort"],
);
assert.ok(command.notifications.some((message) => message.includes("stopped")));
});
test("/workflow stop requires a run ID for multiple active runs without UI", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: {
listRuns: async () =>
["run-1", "run-2"].map(
(runId) =>
({
runId,
status: "running",
request: runId,
lease: { activeParentSessionId: "session-1" },
}) as unknown as WorkflowRun,
),
},
engineForContext: () => {
throw new Error("selection fallback should not create an engine");
},
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("stop", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("/workflow stop <run-id>"),
),
);
});
test("/workflow continue invokes and fences normal compaction callbacks", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Approved plan\n\nImplement safely.", "utf8");
const context: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "smart-compact",
status: "normal-compact-pending",
method: "smart_compact",
outcome: "unavailable",
};
let view = workflowView(planPath, 10, context);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(context);
next.status = "normal-compact-running";
next.method = "normal-compact";
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "complete-normal-compaction") {
if (command.expectedRevision !== view.revision) {
throw new Error("stale compaction callback");
}
const next = structuredClone(view.implementationContext!);
next.status = command.outcome === "failed" ? "failed" : "ready";
next.outcome = command.outcome;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const runtime = {
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime;
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, runtime);
let compactOptions: Parameters<ExtensionCommandContext["compact"]>[0];
const { context: ctx, notifications } = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
compact(options) {
compactOptions = options;
},
});
await registered.command()("continue run-1", ctx);
assert.match(compactOptions?.customInstructions ?? "", /workflow run example-feature/);
assert.match(compactOptions?.customInstructions ?? "", /Approved plan/);
compactOptions?.onComplete?.({
summary: "summary",
firstKeptEntryId: "entry-1",
tokensBefore: 100,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.map((command) => command.action),
["inspect", "continue-context", "complete-normal-compaction"],
);
const completed = commands.at(-1);
assert.equal(
completed?.action === "complete-normal-compaction"
? completed.outcome
: undefined,
"succeeded",
);
compactOptions?.onComplete?.({
summary: "duplicate",
firstKeptEntryId: "entry-1",
tokensBefore: 100,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(notifications.some((message) => message.includes("stale compaction callback")));
});
});
test("cancelled fresh-session replacement clears the source-bound intent", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const context: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 10, context);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = undefined;
next.intentCreatedAt = undefined;
next.intentExpiresAt = undefined;
next.runRevision = 12;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async () => ({ cancelled: true })) as ExtensionCommandContext["newSession"],
});
await registered.command()("continue run-1", command.context);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "continue-context", "recover-context-intent"],
);
assert.equal(view.implementationContext?.sourceSessionFile, undefined);
});
});
test("dashboard continuation immediately enters the fresh-session transition", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const implementationContext: ImplementationContextState = {
gateId: "gate-1",
runRevision: 10,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 10, implementationContext);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 11;
view = workflowView(planPath, 11, next);
return structuredClone(view);
}
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = undefined;
next.runRevision = 12;
view = workflowView(planPath, 12, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const registered = fakeApi();
const dashboardArguments: string[] = [];
registerWorkflowContextTransitions(
registered.api,
{
store: { listRuns: async () => [] },
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime,
async (args) => {
dashboardArguments.push(args);
return { continueRunId: "run-1" };
},
);
let newSessionCalls = 0;
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async () => {
newSessionCalls++;
return { cancelled: true };
}) as ExtensionCommandContext["newSession"],
});
await registered.command()("run-1", command.context);
assert.deepEqual(dashboardArguments, ["run-1"]);
assert.equal(newSessionCalls, 1);
assert.deepEqual(
commands.map((entry) => entry.action),
["inspect", "continue-context", "recover-context-intent"],
);
});
});
test("a later startup recovers an unconsumed claimed handoff", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
await writeFile(planPath, "# Plan", "utf8");
const context: ImplementationContextState = {
gateId: "gate-1",
runRevision: 30,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
sourceSessionFile: "/sessions/source.jsonl",
targetSessionFile: "/sessions/target.jsonl",
strategy: "fresh-session",
status: "claimed-target-awaiting-withSession",
method: "fresh-session",
intentExpiresAt: Date.now() + 60_000,
};
let view = workflowView(planPath, 30, context);
const commands: WorkflowCommand[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "recover-context-intent") {
const next = structuredClone(view.implementationContext!);
next.status = "handoff-pending";
next.targetSessionFile = undefined;
next.runRevision = 31;
view = workflowView(planPath, 31, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const runtime = {
store: {
async listRuns() {
return [
{
runId: view.runId,
revision: view.revision,
status: view.status,
implementationContext: structuredClone(view.implementationContext),
} as WorkflowRun,
];
},
},
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime;
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, runtime);
const target = commandContext({
sessionId: "session-2",
sessionFile: "/sessions/target.jsonl",
}).context;
for (const scenario of [
{ eventReason: "startup", expectedReason: "startup", expired: false },
{ eventReason: "reload", expectedReason: "reload", expired: false },
{ eventReason: "resume", expectedReason: "resume", expired: false },
{ eventReason: "fork", expectedReason: "fork", expired: false },
{ eventReason: "startup", expectedReason: "expired", expired: true },
] as const) {
const pending = structuredClone(context);
pending.intentExpiresAt = scenario.expired
? Date.now() - 1
: Date.now() + 60_000;
view = workflowView(planPath, 30, pending);
commands.length = 0;
await registered.start()({ reason: scenario.eventReason }, target);
const recovered = commands[0];
assert.equal(recovered?.action, "recover-context-intent");
assert.equal(
recovered?.action === "recover-context-intent"
? recovered.reason
: undefined,
scenario.expectedReason,
);
assert.equal(view.implementationContext?.status, "handoff-pending");
}
});
});
test("fresh continuation claims shutdown target and consumes only in withSession", async () => {
await withTempDir(async (directory) => {
const planPath = path.join(directory, "plan.md");
const planPrefix = "# Handoff plan\n";
const boundaryPlan = `${planPrefix}${"a".repeat(
32 * 1024 - 1 - Buffer.byteLength(planPrefix, "utf8"),
)}😀tail`;
await writeFile(planPath, boundaryPlan, "utf8");
const context: ImplementationContextState = {
gateId: "gate-1",
runRevision: 20,
planRevision: 1,
preparationId: "prep-1",
activeParentSessionId: "session-1",
sourceParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
};
let view = workflowView(planPath, 20, context);
const commands: WorkflowCommand[] = [];
const sent: unknown[] = [];
const engine = {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "inspect") return structuredClone(view);
if (command.action === "continue-context") {
const next = structuredClone(view.implementationContext!);
next.sourceSessionFile = command.sourceSessionFile;
next.runRevision = 21;
view = workflowView(planPath, 21, next);
return structuredClone(view);
}
if (command.action === "claim-context-handoff") {
const next = structuredClone(view.implementationContext!);
next.status = "claimed-target-awaiting-withSession";
next.targetSessionFile = command.targetSessionFile;
next.runRevision = 22;
view = workflowView(planPath, 22, next);
return structuredClone(view);
}
if (command.action === "consume-context-handoff") {
const next = structuredClone(view.implementationContext!);
next.status = "ready";
next.activeParentSessionId = "session-2";
next.runRevision = 23;
view = workflowView(planPath, 23, next);
return structuredClone(view);
}
throw new Error(`Unexpected command ${command.action}`);
},
} as WorkflowEngine;
const runtime = {
store: {
async listRuns() {
return [
{
runId: view.runId,
revision: view.revision,
status: view.status,
implementationContext: structuredClone(view.implementationContext),
} as WorkflowRun,
];
},
},
engineForContext: () => engine,
} as unknown as ContextTransitionRuntime;
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, runtime);
const replacement = commandContext({
sessionId: "session-2",
sessionFile: "/sessions/target.jsonl",
}).context as unknown as ExtensionCommandContext & {
sendMessage(message: unknown): Promise<void>;
sendUserMessage(message: string): Promise<void>;
};
replacement.sendMessage = async (message) => {
sent.push(message);
};
replacement.sendUserMessage = async () => {};
const old = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
newSession: (async (options) => {
await registered.shutdown()(
{ reason: "new", targetSessionFile: "/sessions/target.jsonl" },
old.context,
);
await registered.start()(
{ reason: "new", previousSessionFile: "/sessions/source.jsonl" },
replacement,
);
await options?.withSession?.(replacement);
return { cancelled: false };
}) as ExtensionCommandContext["newSession"],
});
await registered.command()("continue run-1", old.context);
assert.equal(sent.length, 1);
const handoffMessage = sent[0] as { content: string };
assert.match(handoffMessage.content, /Continue workflow run example-feature/);
const handoff = JSON.stringify(sent[0]);
assert.match(handoff, /Handoff plan/);
assert.doesNotMatch(handoff, /�|😀/);
assert.deepEqual(
commands.map((command) => command.action),
[
"inspect",
"continue-context",
"claim-context-handoff",
"inspect",
"consume-context-handoff",
],
);
});
});
@@ -0,0 +1,567 @@
import { randomUUID } from "node:crypto";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { WorkflowRun } from "./domain.ts";
import {
type CompleteNormalCompactionCommand,
type ConsumeContextHandoffCommand,
type ContinueContextCommand,
type DecideWorkflowCommand,
type WorkflowCommand,
type WorkflowEngine,
type WorkflowView,
} from "./engine.ts";
import { isValidContextContinuation } from "./continuation.ts";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
import { workflowInput, workflowSelect } from "./tui.ts";
const MAX_HANDOFF_BYTES = 32 * 1024;
type NewSessionOptions = NonNullable<
Parameters<ExtensionCommandContext["newSession"]>[0]
>;
type ReplacementContext = Parameters<
NonNullable<NewSessionOptions["withSession"]>
>[0];
export interface ContextTransitionRuntime {
store: WorkflowStore;
engineForContext(ctx: ExtensionContext): Promise<WorkflowEngine> | WorkflowEngine;
}
function sessionId(ctx: ExtensionContext) {
const id = ctx.sessionManager.getSessionId();
if (!id) throw new Error("Workflow context transition requires a persisted session ID");
return id;
}
function sessionFile(ctx: ExtensionContext) {
const file = ctx.sessionManager.getSessionFile();
if (!file) throw new Error("Workflow context transition requires a persisted session file");
return file;
}
function contextCommand(view: WorkflowView) {
const context = view.implementationContext;
if (!context) throw new Error("Workflow Run has no Implementation Context intent");
return {
runId: view.runId,
gateId: context.gateId,
preparationId: context.preparationId,
planRevision: context.planRevision,
contextRevision: context.runRevision,
expectedRevision: view.revision,
};
}
async function boundedPlan(view: WorkflowView) {
if (!view.planArtifact) throw new Error("Workflow Run has no persisted plan");
const plan = await readVerifiedPlanArtifact(view.planArtifact);
let bytes = 0;
const characters: string[] = [];
for (const character of plan) {
const width = Buffer.byteLength(character, "utf8");
if (bytes + width > MAX_HANDOFF_BYTES) break;
characters.push(character);
bytes += width;
}
return characters.join("");
}
function handoffMessage(view: WorkflowView, plan: string) {
return [
`Continue workflow run ${deriveRunLabel(view)}.`,
`Worktree: ${view.workspace.root ?? "missing"}`,
`Plan revision: ${view.planArtifact?.revision ?? "missing"}`,
"The workflow state remains authoritative. Inspect it before taking action.",
"",
plan,
].join("\n");
}
async function inspect(
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
runId: string,
) {
const engine = await runtime.engineForContext(ctx);
return {
engine,
view: await engine.execute({ action: "inspect", runId }),
};
}
async function completeNormalCompaction(
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
view: WorkflowView,
outcome: CompleteNormalCompactionCommand["outcome"],
error?: string,
) {
const engine = await runtime.engineForContext(ctx);
const command: CompleteNormalCompactionCommand = {
action: "complete-normal-compaction",
...contextCommand(view),
outcome,
...(error ? { error } : {}),
};
await engine.execute(command);
}
async function consumeHandoff(
runtime: ContextTransitionRuntime,
replacementCtx: ReplacementContext,
runId: string,
sourceSessionFile: string,
) {
const current = await inspect(runtime, replacementCtx, runId);
const targetSessionFile = sessionFile(replacementCtx);
const plan = await boundedPlan(current.view);
await replacementCtx.sendMessage({
customType: "workflow-handoff",
content: handoffMessage(current.view, plan),
display: true,
});
const command: ConsumeContextHandoffCommand = {
action: "consume-context-handoff",
...contextCommand(current.view),
targetSessionFile,
previousSessionFile: sourceSessionFile,
};
await current.engine.execute(command);
replacementCtx.ui.notify(
`Workflow ${deriveRunLabel(current.view)} context handoff is ready`,
"info",
);
}
async function continueFreshSession(
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
view: WorkflowView,
) {
const currentSessionId = sessionId(ctx);
const sourceSessionFile = sessionFile(ctx);
if (view.implementationContext?.activeParentSessionId !== currentSessionId) {
throw new Error("Only the active parent session may continue this Workflow Run");
}
const engine = await runtime.engineForContext(ctx);
const command: ContinueContextCommand = {
action: "continue-context",
...contextCommand(view),
sourceSessionFile,
};
const pending = await engine.execute(command);
const result = await ctx.newSession({
parentSession: sourceSessionFile,
withSession: async (replacementCtx) => {
await consumeHandoff(runtime, replacementCtx, view.runId, sourceSessionFile);
},
});
if (result.cancelled) {
const pendingContext = pending.implementationContext;
if (pendingContext) {
await engine.execute({
action: "recover-context-intent",
runId: pending.runId,
gateId: pendingContext.gateId,
preparationId: pendingContext.preparationId,
planRevision: pendingContext.planRevision,
contextRevision: pendingContext.runRevision,
expectedRevision: pending.revision,
reason: "cancelled",
});
}
ctx.ui.notify("Workflow context handoff was cancelled", "warning");
}
}
async function continueNormalCompaction(
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
view: WorkflowView,
) {
const plan = await boundedPlan(view);
const engine = await runtime.engineForContext(ctx);
const command: ContinueContextCommand = {
action: "continue-context",
...contextCommand(view),
};
const compacting = await engine.execute(command);
const instructions = [
`Prepare context for workflow run ${deriveRunLabel(view)}.`,
`Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`,
"Use this approved plan as the implementation focus:",
plan,
].join("\n\n");
try {
ctx.compact({
customInstructions: instructions,
onComplete: () => {
void completeNormalCompaction(runtime, ctx, compacting, "succeeded").catch(
(error: unknown) => {
ctx.ui.notify(
`Workflow compaction callback failed: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
},
);
},
onError: (error) => {
void completeNormalCompaction(
runtime,
ctx,
compacting,
"failed",
error.message || "Normal compaction failed",
).catch((callbackError: unknown) => {
ctx.ui.notify(
`Workflow compaction callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`,
"error",
);
});
},
});
} catch (error) {
await completeNormalCompaction(
runtime,
ctx,
compacting,
"failed",
error instanceof Error ? error.message : String(error),
);
throw error;
}
}
function isInteractive(ctx: ExtensionCommandContext) {
return ctx.hasUI && ctx.mode === "tui";
}
function belongsToActiveParent(run: WorkflowRun, sessionId: string) {
return run.lease?.activeParentSessionId === sessionId;
}
function canContinue(run: WorkflowRun, currentSessionId: string) {
return isValidContextContinuation(
{
...run,
openGate: run.gates.find((gate) => gate.status === "open"),
},
currentSessionId,
);
}
async function selectRun(
ctx: ExtensionCommandContext,
title: string,
runs: WorkflowRun[],
) {
const labels = deriveRunLabels(runs);
return workflowSelect(
ctx,
title,
runs.map((run, index) => ({
value: run,
label: labels[index]!,
description: run.status,
})),
);
}
async function continueRun(
runtime: ContextTransitionRuntime,
runId: string,
ctx: ExtensionCommandContext,
) {
await ctx.waitForIdle();
let view: WorkflowView;
try {
({ view } = await inspect(runtime, ctx, runId));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Workflow Run ${runId} could not be loaded: ${reason}`);
}
const status = view.implementationContext?.status;
if (status === "handoff-pending") {
await continueFreshSession(runtime, ctx, view);
return;
}
if (status === "normal-compact-pending") {
await continueNormalCompaction(runtime, ctx, view);
return;
}
throw new Error(
`Workflow Run ${deriveRunLabel(view)} has no pending context continuation`,
);
}
async function handleContinue(
runtime: ContextTransitionRuntime,
runId: string | undefined,
ctx: ExtensionCommandContext,
) {
if (runId) {
await continueRun(runtime, runId, ctx);
return;
}
if (!isInteractive(ctx)) {
ctx.ui.notify(
"Selecting a Workflow Run to continue requires interactive UI. Use /workflow continue <run-id>.",
"error",
);
return;
}
const currentSessionId = sessionId(ctx);
const runs = (await runtime.store.listRuns()).filter((run) =>
canContinue(run, currentSessionId),
);
if (runs.length === 0) {
ctx.ui.notify("No Workflow Runs are ready to continue.", "info");
return;
}
const selected = await selectRun(ctx, "Continue Workflow Run", runs);
if (selected) await continueRun(runtime, selected.runId, ctx);
}
async function handleStart(
pi: ExtensionAPI,
request: string,
ctx: ExtensionCommandContext,
) {
let initialRequest = request.trim();
if (!initialRequest) {
if (!isInteractive(ctx)) {
ctx.ui.notify(
"A workflow request is required outside interactive UI. Use /workflow start <message>.",
"error",
);
return;
}
initialRequest =
(await workflowInput(
ctx,
"Start Workflow Run",
"What do you want to start working on?",
))?.trim() ?? "";
if (!initialRequest) return;
}
await ctx.waitForIdle();
await pi.sendUserMessage(
[
"Start a managed workflow for the request below.",
"Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke workflow with action \"start\" through the normal assistant tool path.",
"",
initialRequest,
].join("\n"),
);
}
async function handleStop(
runtime: ContextTransitionRuntime,
requestedRunId: string | undefined,
ctx: ExtensionCommandContext,
) {
const currentSessionId = sessionId(ctx);
const activeRuns = (await runtime.store.listRuns()).filter(
(run) =>
(run.status === "running" || run.status === "waiting") &&
belongsToActiveParent(run, currentSessionId),
);
let selected = requestedRunId
? activeRuns.find((run) => run.runId === requestedRunId)
: activeRuns.length === 1
? activeRuns[0]
: undefined;
if (requestedRunId && !selected) {
ctx.ui.notify(
`Workflow Run ${requestedRunId} is not active for this session.`,
"error",
);
return;
}
if (!requestedRunId && activeRuns.length === 0) {
ctx.ui.notify("No active Workflow Runs for this session.", "info");
return;
}
if (!selected && !isInteractive(ctx)) {
ctx.ui.notify(
"Selecting a Workflow Run to stop requires interactive UI. Use /workflow stop <run-id>.",
"error",
);
return;
}
selected ??= await selectRun(ctx, "Stop Workflow Run", activeRuns);
if (!selected) return;
await ctx.waitForIdle();
const engine = await runtime.engineForContext(ctx);
const current = await engine.execute({ action: "inspect", runId: selected.runId });
await engine.execute({
action: "abort",
runId: current.runId,
expectedRevision: current.revision,
idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`,
});
ctx.ui.notify(`Workflow ${deriveRunLabel(current)} stopped.`, "info");
}
export function executeHostDecision(
pi: Pick<ExtensionAPI, "getActiveTools">,
engine: WorkflowEngine,
command: DecideWorkflowCommand,
) {
return engine.execute({
...command,
...(command.decision.kind === "report-context-preparation"
? { activeToolNames: pi.getActiveTools() }
: {}),
});
}
export function registerWorkflowContextTransitions(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
openDashboard?: (
args: string,
ctx: ExtensionCommandContext,
) => Promise<{ continueRunId?: string }>,
) {
pi.registerCommand("workflow", {
description: "Start, stop, inspect, or continue persisted workflow runs",
handler: async (args, ctx) => {
try {
const input = args.trim();
const command = input.match(/^(start|stop|continue|resume)(?:\s+([\s\S]*))?$/);
if (command?.[1] === "start") {
await handleStart(pi, command[2] ?? "", ctx);
return;
}
if (command?.[1] === "stop") {
const runId = command[2]?.trim();
if (runId && /\s/.test(runId)) {
ctx.ui.notify("Usage: /workflow stop [run-id]", "error");
return;
}
await handleStop(runtime, runId || undefined, ctx);
return;
}
if (command?.[1] === "continue" || command?.[1] === "resume") {
const runId = command[2]?.trim();
if (runId && /\s/.test(runId)) {
ctx.ui.notify(
`Usage: /workflow ${command[1]} [run-id]`,
"error",
);
return;
}
await handleContinue(runtime, runId || undefined, ctx);
return;
}
if (openDashboard) {
const result = await openDashboard(input, ctx);
if (result.continueRunId) {
await handleContinue(runtime, result.continueRunId, ctx);
}
return;
}
await handleContinue(runtime, undefined, ctx);
} catch (error) {
ctx.ui.notify(
error instanceof Error ? error.message : String(error),
"error",
);
}
},
});
pi.on("session_start", async (event, ctx) => {
const currentSessionFile = ctx.sessionManager.getSessionFile();
if (!currentSessionFile) return;
for (const run of await runtime.store.listRuns()) {
const context = run.implementationContext;
if (run.status !== "waiting" || context?.status !== "claimed-target-awaiting-withSession") {
continue;
}
const immediateTarget =
event.reason === "new" &&
event.previousSessionFile === context.sourceSessionFile &&
currentSessionFile === context.targetSessionFile &&
(context.intentExpiresAt === undefined || context.intentExpiresAt >= Date.now());
if (immediateTarget) continue;
const engine = await runtime.engineForContext(ctx);
try {
await engine.execute({
action: "recover-context-intent",
runId: run.runId,
gateId: context.gateId,
preparationId: context.preparationId,
planRevision: context.planRevision,
contextRevision: context.runRevision,
expectedRevision: run.revision,
reason:
context.intentExpiresAt !== undefined && context.intentExpiresAt < Date.now()
? "expired"
: event.reason === "reload" ||
event.reason === "resume" ||
event.reason === "fork"
? event.reason
: "startup",
});
} catch (error) {
if (
!(error instanceof Error) ||
!/owned by live process/.test(error.message)
) {
throw error;
}
}
}
});
pi.on("session_shutdown", async (event, ctx) => {
if (event.reason !== "new" || !event.targetSessionFile) return;
const sourceSessionFile = ctx.sessionManager.getSessionFile();
const sourceSessionId = ctx.sessionManager.getSessionId();
if (!sourceSessionFile || !sourceSessionId) return;
for (const run of await runtime.store.listRuns()) {
const context = run.implementationContext;
if (
run.status !== "waiting" ||
context?.status !== "handoff-pending" ||
context.sourceParentSessionId !== sourceSessionId ||
context.sourceSessionFile !== sourceSessionFile
) {
continue;
}
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "claim-context-handoff",
runId: run.runId,
gateId: context.gateId,
preparationId: context.preparationId,
planRevision: context.planRevision,
contextRevision: context.runRevision,
expectedRevision: run.revision,
sourceSessionFile,
targetSessionFile: event.targetSessionFile,
reason: "new",
});
}
});
}
export type ContextTransitionCommand = Extract<
WorkflowCommand,
| { action: "continue-context" }
| { action: "claim-context-handoff" }
| { action: "consume-context-handoff" }
| { action: "complete-normal-compaction" }
| { action: "recover-context-intent" }
>;
export function contextTransitionState(run: WorkflowRun) {
return run.implementationContext;
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isValidContextContinuation } from "./continuation.ts";
function state(overrides: Record<string, unknown> = {}) {
return {
revision: 5,
status: "waiting",
planArtifact: { revision: 2 },
openGate: {
id: "gate-1",
kind: "implementation-context",
status: "open",
},
implementationContext: {
gateId: "gate-1",
runRevision: 4,
planRevision: 2,
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
assignments: [],
...overrides,
};
}
test("continuation requires every persisted binding and the active parent", () => {
assert.equal(isValidContextContinuation(state(), "session-1"), true);
assert.equal(isValidContextContinuation(state(), "session-2"), false);
assert.equal(isValidContextContinuation(state({ status: "running" }), "session-1"), false);
assert.equal(
isValidContextContinuation(
state({
assignments: [
{
status: "queued",
executionToken: "active-token",
},
],
}),
"session-1",
),
false,
);
assert.equal(
isValidContextContinuation(
state({
revision: 6,
}),
"session-1",
),
false,
);
assert.equal(
isValidContextContinuation(
state({
implementationContext: {
...state().implementationContext,
strategy: "smart-compact",
},
}),
"session-1",
),
false,
);
});
test("normal compaction continuation requires unavailable smart compaction state", () => {
const context = {
...state().implementationContext,
strategy: "smart-compact",
status: "normal-compact-pending",
method: "smart_compact",
outcome: "unavailable",
};
assert.equal(
isValidContextContinuation(
state({ implementationContext: context }),
"session-1",
),
true,
);
assert.equal(
isValidContextContinuation(
state({
implementationContext: { ...context, outcome: "failed" },
}),
"session-1",
),
false,
);
});
@@ -0,0 +1,65 @@
interface ContinuationAssignment {
status: string;
executionToken?: string;
}
interface ContinuationGate {
id: string;
kind: string;
status: string;
}
interface ContinuationContext {
gateId: string;
runRevision: number;
planRevision: number;
activeParentSessionId: string;
strategy?: string;
status: string;
method?: string;
outcome?: string;
}
export interface ContinuationState {
revision: number;
status: string;
planArtifact?: { revision: number };
implementationContext?: ContinuationContext;
openGate?: ContinuationGate;
assignments: ContinuationAssignment[];
}
export function isValidContextContinuation(
run: ContinuationState,
sessionId?: string,
) {
const context = run.implementationContext;
const gate = run.openGate;
if (
run.status !== "waiting" ||
!context ||
gate?.status !== "open" ||
gate.kind !== "implementation-context" ||
gate.id !== context.gateId ||
run.planArtifact?.revision !== context.planRevision ||
context.runRevision > run.revision ||
run.revision - context.runRevision > 1 ||
(sessionId && context.activeParentSessionId !== sessionId) ||
run.assignments.some(
(assignment) =>
assignment.status === "running" ||
(assignment.status === "queued" && Boolean(assignment.executionToken)),
)
) {
return false;
}
return (
(context.status === "handoff-pending" &&
context.strategy === "fresh-session" &&
context.method === "fresh-session") ||
(context.status === "normal-compact-pending" &&
context.strategy === "smart-compact" &&
context.method === "smart_compact" &&
context.outcome === "unavailable")
);
}
@@ -0,0 +1,689 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import {
DEFAULT_WORKFLOW_CONFIG,
type WorkflowRun,
} from "./domain.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { openWorkflowDashboard } from "./dashboard.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
runId: "run-1",
revision: 12,
mode: "feature",
status: "waiting",
background: false,
request: "Build search page",
featureName: "search-page",
parentSessionIds: ["session-1"],
lease: {
processId: 100,
activeParentSessionId: "session-1",
heartbeatAt: 1,
},
workspace: {
state: "ready",
sourceRoot: "/repo",
root: "/repo/.worktrees/workflow-search-page-run-1",
branch: "feature/search-page",
baseHead: "abc123",
},
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "design",
taskKind: "feature",
requestTerms: ["search"],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [
{ id: "discover", kind: "discover", status: "completed" },
{ id: "design", kind: "design", status: "waiting" },
{ id: "prototype", kind: "prototype", status: "pending" },
{ id: "implement", kind: "implement", status: "pending" },
{ id: "verify", kind: "verify", status: "pending" },
{ id: "review", kind: "review", status: "pending" },
],
gates: [
{
id: "gate-1",
kind: "plan-approval",
status: "open",
revision: 12,
openedAt: 1,
},
],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [],
commandReceipts: [],
uiImpact: "minor",
uiImpactEvidence: ["One visible field changes"],
affectedUiSurfaces: ["Search page"],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
...overrides,
};
}
function prototypeCandidate() {
return {
id: "candidate-1",
revision: 2,
assignmentId: "prototype-assignment-1",
profileName: "frontend-prototyper",
status: "succeeded" as const,
path: "/repo/prototypes/candidate-1",
route: "/search?variant=1",
structuralRationale: "Compact drill-down flow",
responsiveChecks: ["mobile", "desktop"],
browserEvidence: ["validated in browser"],
launchInstructions: "Run the prototype server.",
visualDigest: "visual-digest",
artifactPath: "/state/artifacts/candidate-1.md",
retainedArtifacts: ["/state/artifacts/candidate-1.png"],
};
}
function view(workflow: WorkflowRun): WorkflowView {
return {
runId: workflow.runId,
featureName: workflow.featureName,
request: workflow.request,
revision: workflow.revision,
status: workflow.status,
background: workflow.background,
workspace: workflow.workspace,
openGate: workflow.gates.find((gate) => gate.status === "open"),
uiImpact: workflow.uiImpact,
uiImpactEvidence: workflow.uiImpactEvidence,
affectedUiSurfaces: workflow.affectedUiSurfaces,
planArtifact: workflow.planArtifact,
reportArtifact: workflow.reportArtifact,
implementationContext: workflow.implementationContext,
stages: workflow.stages,
assignments: workflow.assignments,
mutatorProfileNames: workflow.mutatorProfileNames,
reviewCycles: workflow.reviewCycles,
verificationCycles: workflow.verificationCycles,
};
}
function tuiContext(results: unknown[], sessionId = "session-1") {
const notifications: string[] = [];
const renders: string[][] = [];
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const unsupported = () => {
throw new Error("built-in bounded dialog must not be called");
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: {
getSessionId: () => sessionId,
},
ui: {
custom: async (
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => { render(width: number): string[] },
) => {
customCalls++;
const component = factory(
{
requestRender() {},
terminal: { columns: 100, rows: 24 },
},
theme,
{},
() => {},
);
renders.push(component.render(100));
return results.shift();
},
select: unsupported,
confirm: unsupported,
input: unsupported,
editor: unsupported,
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
return { context, notifications, renders, customCalls: () => customCalls };
}
function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
let customCalls = 0;
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const context = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => sessionId },
ui: {
async custom(
factory: (
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
overlayTheme: typeof theme,
keybindings: object,
done: (value: unknown) => void,
) => {
render(width: number): string[];
handleInput?(data: string): void;
},
) {
customCalls++;
let completed: unknown;
const component = factory(
{
requestRender() {},
terminal: { columns: 80, rows: 16 },
},
theme,
{},
(value) => { completed = value; },
);
component.render(56);
for (const input of inputs.shift() ?? []) component.handleInput?.(input);
return completed;
},
notify() {},
},
} as unknown as ExtensionCommandContext;
return { context, customCalls: () => customCalls };
}
const pi = {
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
test("dashboard fresh-session action returns an immediate continuation result", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
gates: [
{
id: "context-gate",
kind: "implementation-context",
status: "open",
revision: 12,
openedAt: 1,
},
],
implementationContext: {
gateId: "context-gate",
runRevision: 12,
planRevision: 3,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
});
const tui = tuiContext(["run-1", "continue"]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, { continueRunId: "run-1" });
assert.equal(tui.customCalls(), 2);
});
test("dashboard records Plan Approval using only custom TUI overlays", async () => {
const workflow = run({
planArtifact: {
path: "/state/artifacts/plan.md",
revision: 3,
digest: "digest",
},
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
"approve",
true,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
const decision = commands[0];
assert.equal(decision?.action, "decide");
assert.deepEqual(
decision?.action === "decide" ? decision.decision : undefined,
{
kind: "approve-plan",
approved: true,
acceptedUiImpact: "minor",
},
);
assert.ok(tui.customCalls() >= 4);
});
test("prototype reader Escape returns to candidate actions without accepting", async () => {
const candidate = prototypeCandidate();
const workflow = run({
stages: run().stages.map((stage) =>
stage.kind === "prototype"
? { ...stage, status: "waiting" as const }
: stage,
),
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"back",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
assert.deepEqual(commands, []);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype 2"))
.length >= 2,
);
});
test("prototype acceptance requires the explicit candidate Accept action", async () => {
const candidate = prototypeCandidate();
const workflow = run({
gates: [
{
id: "prototype-gate",
kind: "prototype-acceptance",
status: "open",
revision: 12,
openedAt: 1,
},
],
prototypeCandidates: [candidate],
});
const commands: WorkflowCommand[] = [];
const tui = tuiContext([
"run-1",
"decide",
{ kind: "candidate", candidate },
"review",
undefined,
"accept",
true,
"",
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: async () =>
({
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return view(workflow);
},
}) as never,
},
tui.context,
);
const decision = commands[0];
assert.deepEqual(
decision?.action === "decide" ? decision.decision : undefined,
{
kind: "accept-prototype",
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true,
},
);
});
test("Escape from run detail returns one level to the run list", async () => {
const tui = tuiContext(["run-1", undefined, undefined]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard handleInput drives selection and Escape back-stack navigation", async () => {
const tui = interactiveTuiContext([["\r"], ["\x1b"], ["\x1b"]]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.deepEqual(result, {});
assert.equal(tui.customCalls(), 3);
});
test("dashboard duplicate labels retain their canonical run selection IDs", async () => {
const workflows = [
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
];
const tui = tuiContext(["12345678-bbbb", undefined, undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const list = tui.renders[0]?.join("\n") ?? "";
assert.match(list, /search-page · 12345678-a/);
assert.match(list, /search-page · 12345678-b/);
assert.match(tui.renders[1]?.join("\n") ?? "", /search-page · 12345678-b/);
});
test("dashboard disambiguates duplicate labels across the full run list", async () => {
const workflows = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName:
index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const tui = tuiContext([undefined]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => workflows } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const rendered = tui.renders[0]?.join("\n") ?? "";
assert.match(rendered, /shared-label · 12345678-a/);
assert.match(rendered, /1-12 of 30/);
assert.doesNotMatch(rendered, /shared-label\s+WAITING/);
});
test("historical parent sessions receive inspection-only dashboard actions", async () => {
const workflow = run({
parentSessionIds: ["session-1", "session-2"],
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
});
const tui = tuiContext(["run-1", undefined, undefined], "session-2");
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
const actions = tui.renders[1]?.join("\n") ?? "";
assert.match(actions, /Review verified plan/);
assert.doesNotMatch(actions, /Record Gate Decision/);
assert.doesNotMatch(actions, /Continue context transition/);
});
test("dashboard verifies and opens retained report content", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-dashboard-"));
try {
const reportPath = path.join(directory, "report.md");
const reportContents = "# Completed report\n\nVerified.";
await writeFile(reportPath, reportContents, "utf8");
const workflow = run({
status: "completed",
gates: [],
reportArtifact: {
path: reportPath,
digest: createHash("sha256").update(reportContents).digest("hex"),
revision: 12,
},
});
const tui = tuiContext([
"run-1",
"report",
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes("Completed report"),
),
);
assert.ok(tui.renders.some((lines) => lines.join("\n").includes("Verified.")));
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("retained artifact reader Escape returns through artifact and candidate selection", async () => {
const candidate = prototypeCandidate();
const workflow = run({ prototypeCandidates: [candidate] });
const tui = tuiContext([
"run-1",
"prototypes",
candidate,
candidate.artifactPath,
undefined,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Retained artifact"))
.length >= 2,
);
assert.ok(
tui.renders.filter((lines) => lines.join("\n").includes("Prototype evidence"))
.length >= 2,
);
});
test("dashboard verifies and opens Assignment transcripts", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-transcript-"));
try {
const transcriptPath = path.join(directory, "assignment.json");
const transcriptContents = '[{"role":"assistant","text":"verified"}]\n';
await writeFile(transcriptPath, transcriptContents, "utf8");
const workflow = run({
assignments: [
{
id: "assignment-1",
stageId: "review",
role: "review",
profileName: "independent-reviewer",
profileDigest: "digest",
selection: {
eligibleProfiles: [],
selectedProfileName: "independent-reviewer",
rationale: "Review",
},
status: "succeeded",
mutates: false,
ownedPaths: [],
transcriptArtifact: {
path: transcriptPath,
digest: createHash("sha256")
.update(transcriptContents)
.digest("hex"),
},
},
],
});
const assignment = workflow.assignments[0];
assert.ok(assignment);
const tui = tuiContext([
"run-1",
"transcripts",
assignment,
undefined,
undefined,
undefined,
undefined,
]);
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [workflow] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
);
assert.ok(
tui.renders.some((lines) =>
lines.join("\n").includes('"text":"verified"'),
),
);
assert.ok(
tui.renders.filter((lines) =>
lines.join("\n").includes("Assignment transcripts"),
).length >= 2,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("RPC and print dashboards stay linear and never call custom", async () => {
for (const mode of ["rpc", "print", "json"] as const) {
const notifications: string[] = [];
const ctx = {
hasUI: mode === "rpc",
mode,
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("custom must not be called outside TUI mode");
},
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
assert.deepEqual(
await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
),
{},
);
if (mode === "rpc") assert.match(notifications[0] ?? "", /search-page/);
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const tui = tuiContext([]);
const result = await openWorkflowDashboard(
pi,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
tui.context,
"missing-run-id",
);
assert.deepEqual(result, {});
assert.deepEqual(tui.notifications, [
"Workflow Run missing-run-id was not found for this session.",
]);
});
+518
View File
@@ -0,0 +1,518 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { isValidContextContinuation } from "./continuation.ts";
import type {
GateDecision,
PrototypeCandidate,
WorkflowRun,
} from "./domain.ts";
import {
executeHostDecision,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import { deriveRunLabels } from "./run-label.ts";
import {
readVerifiedPlanArtifact,
readVerifiedReportArtifact,
readVerifiedTranscriptArtifact,
} from "./store.ts";
import {
workflowConfirm,
workflowEditor,
workflowInput,
workflowPlanReader,
workflowSelect,
} from "./tui.ts";
const CORE_STAGES = [
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
] as const;
function activeStage(run: WorkflowRun) {
return (
[...run.stages]
.reverse()
.find((stage) => stage.status === "running" || stage.status === "waiting")
?.kind ?? run.status
);
}
function selectedGate(run: WorkflowRun) {
return [...run.gates].reverse().find((gate) => gate.status === "open");
}
function stageSummary(run: WorkflowRun) {
return run.stages
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
.map((stage) => {
const marker =
stage.status === "completed"
? "✓"
: stage.status === "failed"
? "×"
: stage.status === "running" || stage.status === "waiting"
? "●"
: "·";
return `${marker} ${stage.kind}`;
})
.join(" ");
}
function runSummary(run: WorkflowRun, label: string) {
const gate = selectedGate(run);
return [
`${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
`Progress: ${stageSummary(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
...(run.planArtifact ? [`Plan: ${run.planArtifact.path}`] : []),
...(run.reportArtifact ? [`Report: ${run.reportArtifact.path}`] : []),
].join("\n");
}
function idempotencyKey(run: WorkflowRun, decision: GateDecision) {
return `dashboard-${run.runId}-${run.revision}-${decision.kind}-${randomUUID()}`;
}
async function decide(
pi: Pick<ExtensionAPI, "getActiveTools">,
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
run: WorkflowRun,
decision: GateDecision,
) {
const gate = selectedGate(run);
if (!gate) throw new Error("Workflow Run has no open Gate");
const engine = await runtime.engineForContext(ctx);
return executeHostDecision(pi, engine, {
action: "decide",
runId: run.runId,
gateId: gate.id,
expectedRevision: run.revision,
decision,
idempotencyKey: idempotencyKey(run, decision),
});
}
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
const payload = selectedGate(run)?.payload;
if (!payload || typeof payload !== "object") return undefined;
const questions = (payload as { questions?: unknown }).questions;
if (!Array.isArray(questions)) return undefined;
const answers: Record<string, string> = {};
for (const item of questions) {
if (!item || typeof item !== "object") continue;
const id = (item as { id?: unknown }).id;
const prompt = (item as { prompt?: unknown }).prompt;
if (typeof id !== "string" || typeof prompt !== "string") continue;
const answer = await workflowInput(ctx, "Required question", prompt);
if (!answer?.trim()) return undefined;
answers[id] = answer.trim();
}
return { kind: "answer-questions" as const, answers };
}
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
if (!run.planArtifact || !run.uiImpact) return undefined;
while (true) {
const action = await workflowSelect(ctx, "Plan Approval", [
{ value: "inspect" as const, label: "Review verified plan" },
{
value: "approve" as const,
label: `Approve plan · UI impact ${run.uiImpact}`,
},
{ value: "revise" as const, label: "Request revision" },
]);
if (!action) return undefined;
if (action === "inspect") {
const plan = await readVerifiedPlanArtifact(run.planArtifact);
await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
continue;
}
if (action === "revise") {
const feedback = await workflowEditor(
ctx,
"Plan revision feedback",
"Describe the required plan changes.",
);
return feedback?.trim()
? { kind: "revise-plan" as const, feedback: feedback.trim() }
: undefined;
}
const approved = await workflowConfirm(
ctx,
"Approve plan",
`Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
);
return approved
? {
kind: "approve-plan" as const,
approved: true as const,
acceptedUiImpact: run.uiImpact,
}
: undefined;
}
}
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
const candidates = run.prototypeCandidates.slice(-3);
while (true) {
const action = await workflowSelect<
| { kind: "candidate"; candidate: PrototypeCandidate }
| { kind: "refine" }
| { kind: "abort" }
>(ctx, "Prototype Acceptance", [
...candidates.map((candidate, index) => ({
value: { kind: "candidate" as const, candidate },
label: `${index + 1}. ${candidate.structuralRationale}`,
description: candidate.route,
})),
{ value: { kind: "refine" as const }, label: "Request refinement" },
{ value: { kind: "abort" as const }, label: "Abort run" },
]);
if (!action) return undefined;
if (action.kind === "refine") {
const feedback = await workflowEditor(
ctx,
"Prototype refinement feedback",
"Describe what the next candidates must change.",
);
return feedback?.trim()
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
: undefined;
}
if (action.kind === "abort") {
return (await workflowConfirm(
ctx,
"Abort Workflow Run",
"Abort this run and preserve its current evidence?",
))
? ("abort" as const)
: undefined;
}
const candidate = action.candidate;
while (true) {
const candidateAction = await workflowSelect(
ctx,
`Prototype ${candidate.revision}`,
[
{ value: "review" as const, label: "Review candidate evidence" },
{ value: "accept" as const, label: "Accept this candidate" },
{ value: "back" as const, label: "Back to candidates" },
],
);
if (!candidateAction || candidateAction === "back") break;
if (candidateAction === "review") {
await workflowPlanReader(
ctx,
`prototype ${candidate.revision}`,
[
candidate.launchInstructions,
`Route: ${candidate.route}`,
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
].join("\n\n"),
);
continue;
}
const browserReviewed = await workflowConfirm(
ctx,
"Accept prototype",
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
);
if (!browserReviewed) continue;
const feedback = await workflowInput(
ctx,
"Acceptance feedback",
"Optional feedback. Submit an empty value for none.",
);
return {
kind: "accept-prototype" as const,
candidateId: candidate.id,
candidateRevision: candidate.revision,
browserReviewed: true as const,
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
};
}
}
}
async function implementationContextDecision(ctx: ExtensionContext) {
const strategy = await workflowSelect(ctx, "Implementation Context", [
{
value: "fresh-session" as const,
label: "Fresh plan-seeded session",
description: "Replace this session and continue immediately.",
},
{
value: "smart-compact" as const,
label: "Compact current session",
},
{
value: "current-session" as const,
label: "Keep current context unchanged",
},
]);
return strategy
? { kind: "select-implementation-context" as const, strategy }
: undefined;
}
async function recoveryDecision(ctx: ExtensionContext) {
const resolution = await workflowSelect(ctx, "Recovery", [
{ value: "retry" as const, label: "Retry" },
{ value: "inspect" as const, label: "Inspect" },
{ value: "abort" as const, label: "Abort" },
]);
return resolution
? { kind: "recover" as const, resolution }
: undefined;
}
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
const gate = selectedGate(run);
if (!gate) return undefined;
if (gate.kind === "questions") return questionDecision(ctx, run);
if (gate.kind === "plan-approval") return planDecision(ctx, run);
if (gate.kind === "prototype-acceptance") return prototypeDecision(ctx, run);
if (gate.kind === "implementation-context") {
return implementationContextDecision(ctx);
}
return recoveryDecision(ctx);
}
function validContinuation(run: WorkflowRun, sessionId?: string) {
return isValidContextContinuation(
{ ...run, openGate: selectedGate(run) },
sessionId,
);
}
function isActiveParent(run: WorkflowRun, sessionId?: string) {
return Boolean(sessionId && run.lease?.activeParentSessionId === sessionId);
}
async function browsePrototypeArtifacts(ctx: ExtensionContext, run: WorkflowRun) {
while (true) {
const candidate = await workflowSelect(
ctx,
"Prototype evidence",
run.prototypeCandidates.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.structuralRationale}`,
description: entry.route,
})),
);
if (!candidate) return;
while (true) {
const artifact = await workflowSelect(
ctx,
"Retained artifact",
[candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
value: entry,
label: entry,
})),
);
if (!artifact) break;
if (artifact.endsWith(".json")) {
await workflowPlanReader(
ctx,
"prototype evidence",
await readFile(artifact, "utf8"),
);
} else {
await workflowPlanReader(
ctx,
"prototype evidence",
`${candidate.launchInstructions}\n\nRoute: ${candidate.route}\n\nArtifact: ${artifact}`,
);
}
}
}
}
async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
const assignments = run.assignments.filter(
(assignment) => assignment.transcriptArtifact,
);
while (true) {
const assignment = await workflowSelect(
ctx,
"Assignment transcripts",
assignments.map((entry, index) => ({
value: entry,
label: `${index + 1}. ${entry.role}/${entry.profileName}`,
description: entry.status,
})),
);
if (!assignment?.transcriptArtifact) return;
const transcript = await readVerifiedTranscriptArtifact(
assignment.transcriptArtifact,
);
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
}
}
type DashboardActionResult =
| { kind: "back" }
| { kind: "continue"; runId: string }
| { kind: "closed" };
async function actionSheet(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
initialRun: WorkflowRun,
label: string,
): Promise<DashboardActionResult> {
let run = initialRun;
while (true) {
const actions = [
{ value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
: []),
...(validContinuation(run, ctx.sessionManager.getSessionId())
? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
: []),
...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
...(run.prototypeCandidates.length > 0
? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
: []),
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
: []),
...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
];
const action = await workflowSelect(ctx, label, actions);
if (!action) return { kind: "back" };
if (action === "continue") return { kind: "continue", runId: run.runId };
if (action === "inspect") {
await workflowPlanReader(ctx, label, runSummary(run, label));
} else if (action === "plan") {
await workflowPlanReader(
ctx,
label,
await readVerifiedPlanArtifact(run.planArtifact!),
);
} else if (action === "report") {
await workflowPlanReader(
ctx,
`${label} report`,
await readVerifiedReportArtifact(run.reportArtifact!),
);
} else if (action === "prototypes") {
await browsePrototypeArtifacts(ctx, run);
} else if (action === "transcripts") {
await browseAssignmentTranscripts(ctx, run);
} else if (action === "worktree") {
await workflowPlanReader(ctx, "worktree", run.workspace.root!);
} else if (action === "decide") {
const decision = await gateDecision(ctx, run);
if (decision === "abort") {
const engine = await runtime.engineForContext(ctx);
await engine.execute({
action: "abort",
runId: run.runId,
expectedRevision: run.revision,
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
});
} else if (decision) {
const result = await decide(pi, runtime, ctx, run, decision);
if (
decision.kind === "select-implementation-context" &&
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through workflow.`,
"info",
);
} else if (
decision.kind === "select-implementation-context" &&
decision.strategy === "fresh-session"
) {
return { kind: "continue", runId: run.runId };
} else {
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
}
}
const refreshed = (await runtime.store.listRuns()).find((candidate) => candidate.runId === run.runId);
if (!refreshed || ["completed", "failed", "aborted"].includes(refreshed.status)) {
return { kind: "closed" };
}
run = refreshed;
}
}
}
export interface DashboardResult {
continueRunId?: string;
}
export async function openWorkflowDashboard(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
initialRunId?: string,
): Promise<DashboardResult> {
const sessionId = ctx.sessionManager.getSessionId();
const runs = (await runtime.store.listRuns())
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
.sort((left, right) => {
const leftTime = Math.max(left.lease?.heartbeatAt ?? 0, ...left.gates.map((gate) => gate.openedAt));
const rightTime = Math.max(right.lease?.heartbeatAt ?? 0, ...right.gates.map((gate) => gate.openedAt));
return rightTime - leftTime;
});
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
return {};
}
const labels = deriveRunLabels(runs);
if (!ctx.hasUI || ctx.mode !== "tui") {
const visibleRuns = runs.slice(0, 20);
const omitted = runs.length - visibleRuns.length;
ctx.ui.notify(
runs.length > 0
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
: "No Workflow Runs.",
"info",
);
return {};
}
let selectedRunId = initialRunId;
while (true) {
if (!selectedRunId) {
selectedRunId = await workflowSelect(
ctx,
"workflow",
runs.map((run, index) => ({
value: run.runId,
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
description: selectedGate(run)?.kind,
})),
);
if (!selectedRunId) return {};
}
const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
const run = runs[index];
if (!run) return {};
const result = await actionSheet(pi, runtime, ctx, run, labels[index]!);
if (result.kind === "continue") return { continueRunId: result.runId };
if (result.kind === "closed") return {};
selectedRunId = undefined;
}
}
+491
View File
@@ -0,0 +1,491 @@
export const PROFILE_ROLES = [
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
] as const;
export type ProfileRole = (typeof PROFILE_ROLES)[number];
export type AssignmentStageKind = ProfileRole;
export type WorkflowStageKind =
| "initialize"
| AssignmentStageKind
| "remediate"
| "complete"
| `custom:${string}`;
export const REASONING_EFFORTS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
export interface ProfileSelection {
stages: AssignmentStageKind[];
anyTags: string[];
allTags: string[];
pathGlobs: string[];
keywords: string[];
}
export interface AgentProfile {
name: string;
description: string;
roles: ProfileRole[];
model?: string;
provider?: string;
effort?: ReasoningEffort;
tools?: string[];
selection: ProfileSelection;
instructions: string;
source:
| "bundled"
| "project"
| "standard-package"
| "standard-global"
| "standard-project";
sourcePath: string;
resolvedPath: string;
digest: string;
}
export interface ProfileDiagnostic {
sourcePath: string;
severity: "warning" | "error";
message: string;
}
export interface ProfileCatalog {
profiles: AgentProfile[];
diagnostics: ProfileDiagnostic[];
}
export interface WorkflowFacts {
stage: AssignmentStageKind;
taskKind: "feature" | "analysis";
requestTerms: string[];
candidatePaths: string[];
languages: string[];
tags: string[];
riskTags: string[];
}
export interface EligibilityResult {
profile: AgentProfile;
eligible: boolean;
reasons: string[];
specificity: number;
}
export type RunStatus =
| "running"
| "waiting"
| "completed"
| "failed"
| "aborted";
export type AssignmentStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "interrupted";
export type StageStatus =
| "pending"
| "running"
| "waiting"
| "completed"
| "failed";
export type GateKind =
| "questions"
| "plan-approval"
| "prototype-acceptance"
| "implementation-context"
| "recovery";
export type UiImpact = "none" | "minor" | "non-trivial";
export type GateDecision =
| { kind: "answer-questions"; answers: Record<string, string> }
| {
kind: "approve-plan";
approved: true;
acceptedUiImpact: UiImpact;
comment?: string;
}
| { kind: "revise-plan"; feedback: string }
| {
kind: "accept-prototype";
candidateId: string;
candidateRevision: number;
browserReviewed: true;
feedback?: string;
}
| { kind: "refine-prototypes"; feedback: string }
| {
kind: "select-implementation-context";
strategy:
| "fresh-session"
| "smart-compact"
| "current-session";
}
| {
kind: "report-context-preparation";
preparationId: string;
planRevision: number;
method: "smart_compact";
outcome: "unavailable" | "succeeded" | "no-op";
}
| {
kind: "report-context-preparation";
preparationId: string;
planRevision: number;
method: "smart_compact";
outcome: "failed";
error: string;
}
| { kind: "recover"; resolution: "retry" | "inspect" | "abort" };
export interface RunLease {
processId: number;
activeParentSessionId: string;
heartbeatAt: number;
worktreeIdentity?: string;
}
export interface SourceChangeEvidence {
path: string;
status: string;
}
export interface WorkflowWorkspace {
state: "not-required" | "creating" | "ready" | "failed";
sourceRoot: string;
root?: string;
branch?: string;
baseHead?: string;
sourceChanges?: SourceChangeEvidence[];
sourceChangesOmitted?: number;
dirtySourceAllowed?: boolean;
error?: string;
}
export interface WorkflowStage {
id: string;
kind: WorkflowStageKind;
status: StageStatus;
startedAt?: number;
finishedAt?: number;
output?: unknown;
}
export interface WorkflowGate {
id: string;
kind: GateKind;
status: "open" | "satisfied" | "rejected";
revision: number;
openedAt: number;
payload?: unknown;
}
export interface WorkflowDecision {
gateId: string;
revision: number;
idempotencyKey: string;
decidedAt: number;
value: GateDecision;
}
export interface ProfileSelectionEvidence {
profileName: string;
profileDigest: string;
eligible: boolean;
reasons: string[];
specificity: number;
}
export interface AssignmentSelection {
eligibleProfiles: ProfileSelectionEvidence[];
selectedProfileName: string;
rationale: string;
}
export interface StageEligibilityEvidence {
role: ProfileRole;
candidates: ProfileSelectionEvidence[];
}
export interface AssignmentTranscriptArtifact {
path: string;
digest: string;
}
export interface AssignmentGitState {
root: string;
branch: string;
head: string;
commonDirectory: string;
gitDirectory: string;
worktreeListDigest: string;
worktreeMetadataDigest: string;
status: string;
changedPaths: string[];
pathStatuses: Record<string, string>;
pathDigests: Record<string, string>;
}
export interface VerificationCommand {
id: string;
executable: string;
args: string[];
ownedPaths: string[];
kind: "test" | "typecheck" | "lint" | "build" | "browser" | "generation";
status:
| "pending"
| "running"
| "succeeded"
| "failed"
| "interrupted"
| "superseded";
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}
export interface VerificationEvidence {
commands: VerificationCommand[];
focusedBehavioralTests: boolean;
verifierSucceeded: boolean;
}
export interface ReviewAxis {
verdict: "approved" | "changes_requested";
findings: Array<{ severity: string; message: string; path?: string }>;
}
export interface ReviewVerdict {
specification: ReviewAxis;
standards: ReviewAxis;
}
export interface WorkflowAssignment {
id: string;
stageId: string;
role: ProfileRole | "remediate";
profileName: string;
profileDigest: string;
selection: AssignmentSelection;
status: AssignmentStatus;
mutates: boolean;
ownedPaths: string[];
scriptRequestId?: number;
scriptRequestFingerprint?: string;
executionToken?: string;
childSessionId?: string;
effectiveProvider?: string;
effectiveModel?: string;
effectiveEffort?: string;
contextCapacity?: number;
transcriptArtifact?: AssignmentTranscriptArtifact;
gitBefore?: AssignmentGitState;
gitAfter?: AssignmentGitState;
result?: unknown;
error?: string;
}
export interface PrototypeCandidate {
id: string;
revision: number;
assignmentId: string;
profileName: string;
status: "succeeded";
path: string;
route: string;
structuralRationale: string;
responsiveChecks: string[];
browserEvidence: string[];
launchInstructions: string;
visualDigest: string;
artifactPath: string;
retainedArtifacts: string[];
}
export interface DeliveryRecord {
key: string;
state: "pending" | "sent";
createdAt: number;
sentAt?: number;
}
export interface WorkflowReportArtifact {
path: string;
digest: string;
revision: number;
}
export interface PlanArtifact {
path: string;
revision: number;
digest: string;
}
export interface ImplementationContextState {
gateId: string;
runRevision: number;
planRevision: number;
preparationId: string;
activeParentSessionId: string;
sourceParentSessionId?: string;
sourceSessionFile?: string;
targetSessionFile?: string;
claimedAt?: number;
intentCreatedAt?: number;
intentExpiresAt?: number;
strategy?: "fresh-session" | "smart-compact" | "current-session";
status:
| "waiting"
| "handoff-pending"
| "claimed-target-awaiting-withSession"
| "smart-compact-pending"
| "normal-compact-pending"
| "normal-compact-running"
| "ready"
| "failed";
method?:
| "fresh-session"
| "smart_compact"
| "normal-compact"
| "unchanged";
outcome?: "unavailable" | "succeeded" | "no-op" | "failed";
error?: string;
}
export interface WorkflowError {
code: string;
message: string;
stageId?: string;
assignmentId?: string;
}
export type CommandContinuation =
| "none"
| "design"
| "prototype"
| "implementation"
| "recovery";
export interface CommandReceipt {
key: string;
action: "decide" | "abort";
fingerprint: string;
status: "pending" | "completed";
acceptedRevision: number;
continuation?: CommandContinuation;
result?: unknown;
}
export type FeatureScriptInsertionPoint =
| "before-design"
| "after-design"
| "before-review";
export interface WorkflowScriptState {
source: string;
args?: unknown;
insertionPoint?: FeatureScriptInsertionPoint;
activeToolNames: string[];
status: "pending" | "running" | "completed" | "failed";
stages: string[];
executed: boolean;
result?: unknown;
error?: string;
}
export interface WorkflowRun {
runId: string;
revision: number;
mode: "feature" | "analysis";
script?: WorkflowScriptState;
status: RunStatus;
background: boolean;
request: string;
featureName?: string;
parentSessionIds: string[];
planArtifact?: PlanArtifact;
reportArtifact?: WorkflowReportArtifact;
implementationContext?: ImplementationContextState;
lease?: RunLease;
workspace: WorkflowWorkspace;
effectiveConfig: WorkflowConfig;
pinnedProfiles: AgentProfile[];
preflightEligibility: StageEligibilityEvidence[];
facts: WorkflowFacts;
stages: WorkflowStage[];
gates: WorkflowGate[];
decisions: WorkflowDecision[];
assignments: WorkflowAssignment[];
prototypeCandidates: PrototypeCandidate[];
prototypeCleanupCompleted: boolean;
deliveries: DeliveryRecord[];
commandReceipts: CommandReceipt[];
uiImpact?: UiImpact;
uiImpactEvidence: string[];
affectedUiSurfaces: string[];
mutatorProfileNames: string[];
reviewCycles: number;
verificationCycles: number;
verificationEvidence?: VerificationEvidence;
reviewVerdict?: ReviewVerdict;
error?: WorkflowError;
}
export const ANALYSIS_TOOL_ALLOWLIST = ["read", "grep", "find", "ls"] as const;
export const MAX_CONCURRENCY = 32;
export const MAX_ASSIGNMENTS_PER_RUN = 1_024;
export const MAX_REVIEW_CYCLES = 20;
export interface StandardAgentConfig {
enabled: boolean;
roles: Record<string, ProfileRole[]>;
}
export interface WorkflowConfig {
concurrency: {
default: number;
ceiling: number;
};
maxAssignmentsPerRun: number;
maxReviewCycles: number;
analysisTools: string[];
standardAgents: StandardAgentConfig;
worktrees: {
root: string;
};
}
export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = {
concurrency: {
default: 8,
ceiling: MAX_CONCURRENCY,
},
maxAssignmentsPerRun: 128,
maxReviewCycles: 3,
analysisTools: [...ANALYSIS_TOOL_ALLOWLIST],
standardAgents: {
enabled: true,
roles: {
scout: ["discover"],
worker: ["implement"],
},
},
worktrees: {
root: ".worktrees",
},
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,871 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import type {
DefaultResourceLoader,
ExtensionContext,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { emptyUsage } from "../_workflows/model.ts";
import type {
RunAgentOptions,
ThinkingLevel,
WorkflowModel,
} from "../_workflows/runner.ts";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import { WorkflowEngine, type WorkspaceManager } from "./engine.ts";
import {
PiAssignmentExecutor,
ProcessAssignmentScheduler,
resolveWorkflowModel,
} from "./execution.ts";
import { loadAgentProfiles } from "./profiles.ts";
import { WorkflowStore } from "./store.ts";
const execFileAsync = promisify(execFile);
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-execution-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
function model(provider: string, id: string) {
return {
provider,
id,
contextWindow: 128_000,
} as unknown as WorkflowModel;
}
function registry(models: WorkflowModel[]) {
return {
find(provider: string, id: string) {
return models.find((candidate) => candidate.provider === provider && candidate.id === id);
},
getAll() {
return models;
},
} as unknown as ExtensionContext["modelRegistry"];
}
function deferred() {
let resolve: (() => void) | undefined;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve: () => resolve?.() };
}
test("process scheduler enforces aggregate and per-run capacity with queued cancellation", async () => {
const scheduler = new ProcessAssignmentScheduler();
scheduler.lowerCeiling(2);
const waits = [deferred(), deferred(), deferred(), deferred()];
const controllers = waits.map(() => new AbortController());
let active = 0;
let maximum = 0;
const tasks = waits.map((wait, index) =>
scheduler.schedule({
runId: index < 3 ? "run-a" : "run-b",
runLimit: 2,
signal: controllers[index].signal,
task: async () => {
active++;
maximum = Math.max(maximum, active);
try {
await wait.promise;
return index;
} finally {
active--;
}
},
}),
);
const cancelled = tasks[2].catch((error: unknown) => error);
assert.deepEqual(scheduler.snapshot(), {
ceiling: 2,
inUse: 2,
running: 0,
queued: 2,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(scheduler.snapshot(), {
ceiling: 2,
inUse: 2,
running: 2,
queued: 2,
});
controllers[2].abort(new Error("cancel queued"));
assert.match(String(await cancelled), /cancel queued/);
waits[0].resolve();
waits[1].resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
waits[3].resolve();
assert.deepEqual(await Promise.all([tasks[0], tasks[1], tasks[3]]), [0, 1, 3]);
assert.equal(maximum, 2);
assert.deepEqual(scheduler.snapshot(), {
ceiling: 2,
inUse: 0,
running: 0,
queued: 0,
});
});
test("exclusive mutation reservations serialize a run without blocking other runs", async () => {
const scheduler = new ProcessAssignmentScheduler();
scheduler.lowerCeiling(3);
const waits = [deferred(), deferred(), deferred(), deferred()];
const started: number[] = [];
const schedule = (index: number, runId: string, exclusive = false) =>
scheduler.schedule({
runId,
runLimit: 3,
exclusive,
signal: new AbortController().signal,
async task() {
started.push(index);
await waits[index].promise;
},
});
const firstRead = schedule(0, "run-a");
const mutation = schedule(1, "run-a", true);
const laterRead = schedule(2, "run-a");
const otherRun = schedule(3, "run-b");
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(started, [0, 3]);
waits[0].resolve();
await firstRead;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(started, [0, 3, 1]);
waits[1].resolve();
await mutation;
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(started, [0, 3, 1, 2]);
waits[2].resolve();
waits[3].resolve();
await Promise.all([laterRead, otherRun]);
});
test("running cancellation releases a scheduler reservation exactly once", async () => {
const scheduler = new ProcessAssignmentScheduler();
const controller = new AbortController();
let reserved = false;
let started = false;
const task = scheduler.schedule({
runId: "run-a",
runLimit: 1,
signal: controller.signal,
async onReserved() {
reserved = true;
},
async task(signal) {
started = true;
return new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
});
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort(new Error("cancel running"));
await assert.rejects(task, /cancel running/);
assert.equal(reserved, true);
assert.equal(started, true);
assert.deepEqual(scheduler.snapshot(), {
ceiling: 32,
inUse: 0,
running: 0,
queued: 0,
});
});
test("model resolution is exact, inherited-provider aware, and ambiguity safe", () => {
const inherited = model("provider-a", "parent");
const sharedA = model("provider-a", "shared");
const sharedB = model("provider-b", "shared");
const models = registry([inherited, sharedA, sharedB]);
assert.equal(resolveWorkflowModel(models, {}, inherited), inherited);
assert.throws(
() => resolveWorkflowModel(registry([]), {}, inherited),
/Unknown inherited model/,
);
assert.equal(resolveWorkflowModel(models, { model: "shared" }, inherited), sharedA);
assert.equal(
resolveWorkflowModel(models, { provider: "provider-b", model: "shared" }, inherited),
sharedB,
);
assert.throws(
() => resolveWorkflowModel(models, { model: "shared" }, undefined),
/multiple providers/,
);
assert.throws(
() => resolveWorkflowModel(models, { provider: "provider-a" }, inherited),
/requires a model/,
);
});
test("the immutable process ceiling caps aggregate reservations at 32", async () => {
const scheduler = new ProcessAssignmentScheduler();
const waits = Array.from({ length: 40 }, deferred);
let maximum = 0;
let active = 0;
const tasks = waits.map((wait, index) =>
scheduler.schedule({
runId: `run-${index % 4}`,
runLimit: 32,
signal: new AbortController().signal,
task: async () => {
active++;
maximum = Math.max(maximum, active);
try {
await wait.promise;
} finally {
active--;
}
},
}),
);
assert.equal(scheduler.snapshot().inUse, 32);
assert.equal(scheduler.snapshot().queued, 8);
await new Promise((resolve) => setTimeout(resolve, 0));
waits.forEach((wait) => wait.resolve());
await Promise.all(tasks);
assert.equal(maximum, 32);
assert.equal(scheduler.snapshot().inUse, 0);
});
test("production executor resolves trust and creates fresh structured child runs", async () => {
await withTempDir(async (directory) => {
const profiles = (
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
).profiles;
const profile = profiles.find((candidate) => candidate.roles.includes("discover"));
assert.ok(profile);
const inherited = model("provider-a", "parent");
const models = registry([inherited]);
const scheduler = new ProcessAssignmentScheduler();
let resources = 0;
let sessions = 0;
const trustedChildren: string[] = [];
const observedTools: string[][] = [];
const observedCustomTools: string[][] = [];
const fakeResources = {
loader: {} as DefaultResourceLoader,
settingsManager: {} as SettingsManager,
};
const fakeRunAgent = async (options: RunAgentOptions) => {
sessions++;
observedTools.push(options.tools ?? []);
observedCustomTools.push(
options.customTools?.map((tool) => tool.name) ?? [],
);
options.onSessionCreated?.({
childSessionId: `child-${sessions}`,
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
});
return {
ok: true,
output: "",
structured: {
questions: [],
candidatePaths: ["src/index.ts"],
languages: ["typescript"],
tags: ["backend"],
riskTags: [],
},
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: `child-${sessions}`,
contextWindow: inherited.contextWindow,
transcript: [{ role: "assistant" as const, text: "done" }],
};
};
const executor = new PiAssignmentExecutor({
modelRegistry: models,
parentCwd: directory,
parentProjectTrusted: true,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 4,
runConcurrency: 2,
scheduler,
runAgent: fakeRunAgent,
async createResources() {
resources++;
return fakeResources;
},
resolveProjectTrust(options) {
trustedChildren.push(options.childCwd);
return false;
},
});
const makeRequest = (runId: string) => ({
runId,
role: "discover" as const,
request: "Inspect the repository",
profile,
facts: {
stage: "discover" as const,
taskKind: "feature" as const,
requestTerms: ["inspect"],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
decisions: [],
cwd: path.join(directory, "worktree"),
signal: new AbortController().signal,
});
const first = await executor.run(makeRequest("run-a"));
const second = await executor.run(makeRequest("run-b"));
await executor.run({
...makeRequest("run-c"),
allowedReadTools: ["grep"],
});
assert.equal(first.kind, "workflow-assignment-result");
assert.equal(first.metadata.childSessionId, "child-1");
assert.equal(second.metadata.childSessionId, "child-2");
assert.equal(resources, 3);
assert.equal(sessions, 3);
assert.deepEqual(trustedChildren, [
path.join(directory, "worktree"),
path.join(directory, "worktree"),
path.join(directory, "worktree"),
]);
assert.ok(observedTools.every((tools) => tools.includes("structured_output")));
assert.equal(observedTools[2]?.includes("read"), false);
assert.equal(observedTools[2]?.includes("grep"), true);
assert.deepEqual(observedCustomTools[2], ["grep"]);
const failedExecutor = new PiAssignmentExecutor({
modelRegistry: models,
parentCwd: directory,
parentProjectTrusted: true,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 4,
runConcurrency: 2,
scheduler: new ProcessAssignmentScheduler(),
async createResources() {
return fakeResources;
},
async runAgent(options) {
options.onSessionCreated?.({
childSessionId: "child-failed",
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
});
return {
ok: false,
output: "partial",
error: "provider failed",
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: "child-failed",
contextWindow: inherited.contextWindow,
transcript: [{ role: "assistant" as const, text: "partial" }],
};
},
});
await assert.rejects(
failedExecutor.run(makeRequest("run-failed")),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.equal(error.message, "provider failed");
assert.equal(
(error as Error & { assignmentMetadata?: { childSessionId?: string } })
.assignmentMetadata?.childSessionId,
"child-failed",
);
return true;
},
);
});
});
test("mutating children receive guarded file tools without process execution", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await writeFile(path.join(directory, "src", "existing.ts"), "export {};\n", "utf8");
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
const profiles = (
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
).profiles;
const profile = profiles.find((candidate) => candidate.roles.includes("implement"));
assert.ok(profile);
const standardWorker = {
...profile,
name: "worker",
source: "standard-package" as const,
tools: [...(profile.tools ?? []), "bash", "subagent"],
};
const inherited = model("provider-a", "parent");
const executor = new PiAssignmentExecutor({
modelRegistry: registry([inherited]),
parentCwd: directory,
parentProjectTrusted: false,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 2,
runConcurrency: 1,
scheduler: new ProcessAssignmentScheduler(),
async createResources() {
return {
loader: {} as DefaultResourceLoader,
settingsManager: {} as SettingsManager,
};
},
async runAgent(options) {
assert.deepEqual(options.tools, [
"read",
"grep",
"find",
"ls",
"edit",
"write",
"structured_output",
]);
assert.equal(options.tools?.includes("bash"), false);
const writeTool = options.customTools?.find((tool) => tool.name === "write");
assert.ok(writeTool);
await writeTool.execute(
"write-1",
{ path: "src/generated.ts", content: "export const generated = true;\n" },
options.signal ?? new AbortController().signal,
undefined,
{} as ExtensionContext,
);
return {
ok: true,
output: "",
structured: {
plan: "# Plan",
uiImpact: "none",
uiImpactEvidence: ["No user-facing change"],
affectedUiSurfaces: [],
assignments: [],
},
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: "mutating-child",
contextWindow: inherited.contextWindow,
transcript: [],
};
},
});
const result = await executor.run({
runId: "run-mutation",
role: "design",
request: "Generate an owned file",
profile: standardWorker,
facts: {
stage: "design",
taskKind: "feature",
requestTerms: ["generate"],
candidatePaths: ["src/generated.ts"],
languages: ["typescript"],
tags: [],
riskTags: [],
},
decisions: [],
cwd: directory,
signal: new AbortController().signal,
mutates: true,
ownedPaths: ["src"],
});
assert.deepEqual(result.metadata.gitAfter?.changedPaths, ["src/generated.ts"]);
const prototypePath = path.join(directory, "prototypes", "candidate-1");
await mkdir(prototypePath, { recursive: true });
await writeFile(path.join(prototypePath, "route.ts"), "prototype\n", "utf8");
const cleanupExecutor = new PiAssignmentExecutor({
modelRegistry: registry([inherited]),
parentCwd: directory,
parentProjectTrusted: false,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 2,
runConcurrency: 1,
scheduler: new ProcessAssignmentScheduler(),
async createResources() {
return {
loader: {} as DefaultResourceLoader,
settingsManager: {} as SettingsManager,
};
},
async runAgent(options) {
assert.equal(options.tools?.includes("workflow_remove"), true);
const removeTool = options.customTools?.find(
(tool) => tool.name === "workflow_remove",
);
assert.ok(removeTool);
await removeTool.execute(
"remove-prototype",
{ path: "prototypes/candidate-1" },
options.signal ?? new AbortController().signal,
undefined,
{} as ExtensionContext,
);
return {
ok: true,
output: "",
structured: {
summary: "Removed prototype-only route",
changedPaths: ["prototypes/candidate-1"],
focusedTests: [],
},
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: "cleanup-child",
contextWindow: inherited.contextWindow,
transcript: [],
};
},
});
await cleanupExecutor.run({
runId: "run-cleanup",
role: "remediate",
request: "Remove prototype-only routes",
profile,
facts: {
stage: "implement",
taskKind: "feature",
requestTerms: ["cleanup"],
candidatePaths: ["prototypes/candidate-1"],
languages: ["typescript"],
tags: ["frontend"],
riskTags: [],
},
decisions: [],
cwd: directory,
signal: new AbortController().signal,
mutates: true,
ownedPaths: ["prototypes/candidate-1"],
});
await assert.rejects(access(prototypePath));
const reviewProfile = profiles.find((candidate) => candidate.roles.includes("review"));
assert.ok(reviewProfile);
const reviewRequest = {
runId: "run-review",
role: "review" as const,
request: "Review the feature",
profile: reviewProfile,
facts: {
stage: "review" as const,
taskKind: "feature" as const,
requestTerms: ["review"],
candidatePaths: ["src/generated.ts"],
languages: ["typescript"],
tags: [],
riskTags: [],
},
decisions: [],
cwd: directory,
signal: new AbortController().signal,
captureGitState: true,
approvedPlan: "# Approved plan",
verificationEvidence: {
commands: [],
focusedBehavioralTests: true,
verifierSucceeded: true,
},
reviewDiff: "a😀b",
};
const reviewExecutor = (
readCompleteDiff: boolean,
readCompletePlan = readCompleteDiff,
) =>
new PiAssignmentExecutor({
modelRegistry: registry([inherited]),
parentCwd: directory,
parentProjectTrusted: false,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 2,
runConcurrency: 1,
scheduler: new ProcessAssignmentScheduler(),
async createResources() {
return {
loader: {} as DefaultResourceLoader,
settingsManager: {} as SettingsManager,
};
},
async runAgent(options) {
assert.equal(options.tools?.includes("workflow_diff"), true);
assert.equal(options.tools?.includes("workflow_plan"), true);
assert.equal(
options.customTools?.some((tool) => tool.name === "workflow_diff"),
true,
);
assert.equal(
options.customTools?.some((tool) => tool.name === "workflow_plan"),
true,
);
const diffTool = options.customTools?.find(
(tool) => tool.name === "workflow_diff",
);
assert.ok(diffTool);
const executeTool = diffTool.execute as unknown as (
id: string,
params: { offset: number; limit: number },
) => Promise<{ content: Array<{ type: string; text: string }> }>;
const unicodePage = await executeTool("unicode-page", {
offset: 1,
limit: 1,
});
assert.equal(unicodePage.content[0]?.text, "😀");
return {
ok: true,
output: "",
structured: {
specification: { verdict: "approved", findings: [] },
standards: { verdict: "approved", findings: [] },
},
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: "review-child",
contextWindow: inherited.contextWindow,
transcript: [
...(readCompletePlan
? [
{
role: "tool" as const,
name: "workflow_plan",
toolCallId: "plan-call",
text: '{"offset":0,"limit":49152}',
},
{
role: "toolResult" as const,
name: "workflow_plan",
toolCallId: "plan-call",
text: "plan page",
isError: false,
},
]
: []),
...(readCompleteDiff
? [
{
role: "tool" as const,
name: "workflow_diff",
toolCallId: "diff-call",
text: '{"offset":0,"limit":49152}',
},
{
role: "toolResult" as const,
name: "workflow_diff",
toolCallId: "diff-call",
text: "diff page",
isError: false,
},
]
: []),
],
};
},
});
await reviewExecutor(true).run(reviewRequest);
await assert.rejects(
reviewExecutor(false).run(reviewRequest),
/did not read the complete workflow diff/,
);
await assert.rejects(
reviewExecutor(true, false).run(reviewRequest),
/did not read the complete approved workflow plan/,
);
});
});
test("production executor drives Discover and Design through Plan Approval", async () => {
await withTempDir(async (directory) => {
const profiles = (
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
).profiles;
const profileName = (role: "implement" | "verify" | "review") => {
const profile = profiles.find((candidate) => candidate.roles.includes(role));
assert.ok(profile);
return profile.name;
};
const inherited = model("provider-a", "parent");
const models = registry([inherited]);
let sessions = 0;
const fakeRunAgent = async (options: RunAgentOptions) => {
sessions++;
options.onSessionCreated?.({
childSessionId: `pipeline-child-${sessions}`,
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
});
const structured = options.prompt.includes("Assignment role: discover")
? {
questions: [],
candidatePaths: ["src/index.ts"],
languages: ["typescript"],
tags: ["backend"],
riskTags: [],
}
: {
plan: "# Plan\n\nImplement and verify the feature.",
uiImpact: "none",
uiImpactEvidence: ["No user-facing change"],
affectedUiSurfaces: [],
acceptanceCriteria: ["Feature behavior passes focused tests"],
verificationCommands: [
{
executable: "npm",
args: ["test"],
ownedPaths: [],
kind: "test",
},
],
assignments: [
{
role: "implement",
profileName: profileName("implement"),
rationale: "Implement the approved scope",
mutates: true,
ownedPaths: ["src"],
},
{
role: "verify",
profileName: profileName("verify"),
rationale: "Run focused verification",
mutates: false,
ownedPaths: [],
},
{
role: "review",
profileName: profileName("review"),
rationale: "Review independently",
mutates: false,
ownedPaths: [],
},
],
};
return {
ok: true,
output: "",
structured,
aborted: false,
usage: emptyUsage(),
provider: inherited.provider,
model: inherited.id,
thinkingLevel: "medium" as ThinkingLevel,
childSessionId: `pipeline-child-${sessions}`,
contextWindow: inherited.contextWindow,
transcript: [{ role: "assistant" as const, text: "done" }],
};
};
const executor = new PiAssignmentExecutor({
modelRegistry: models,
parentCwd: directory,
parentProjectTrusted: false,
parentModel: inherited,
parentThinkingLevel: "medium" as ThinkingLevel,
concurrencyCeiling: 4,
runConcurrency: 2,
scheduler: new ProcessAssignmentScheduler(),
runAgent: fakeRunAgent,
async createResources() {
return {
loader: {} as DefaultResourceLoader,
settingsManager: {} as SettingsManager,
};
},
});
const workspace: WorkspaceManager = {
async prepare(options) {
return {
sourceRoot: options.sourceRoot,
worktreeRoot: path.join(options.sourceRoot, ".worktrees"),
worktreePath: path.join(options.sourceRoot, ".worktrees", options.runId),
branch: `feature/${options.featureName}`,
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
},
async create(plan): Promise<WorkflowRun["workspace"]> {
return {
state: "ready",
sourceRoot: plan.sourceRoot,
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
};
},
async reconcile(current) {
return current;
},
};
let id = 0;
const engine = new WorkflowEngine({
store: new WorkflowStore(path.join(directory, "state")),
workspace,
executor,
sourceRoot: directory,
parentSessionId: "session-1",
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
profiles,
now: () => 10_000,
createId: () => `pipeline-${++id}`,
processId: 100,
isProcessAlive: () => true,
});
const result = await engine.execute({
action: "start",
request: "Build the feature",
featureName: "build-feature",
idempotencyKey: "pipeline-start",
});
assert.equal(result.openGate?.kind, "plan-approval");
assert.equal(sessions, 2);
assert.deepEqual(
result.assignments
.filter((assignment) => assignment.childSessionId)
.map((assignment) => assignment.childSessionId),
["pipeline-child-1", "pipeline-child-2"],
);
assert.deepEqual(
result.assignments
.filter((assignment) => assignment.status === "queued")
.map((assignment) => assignment.role),
["implement", "verify", "review"],
);
});
});
+817
View File
@@ -0,0 +1,817 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import { access, readFile } from "node:fs/promises";
import {
createEditToolDefinition,
createFindToolDefinition,
createGrepToolDefinition,
createLsToolDefinition,
createReadToolDefinition,
createWriteToolDefinition,
defineTool,
type DefaultResourceLoader,
type ExtensionContext,
type SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
createChildResources,
resolveStandaloneChildProjectTrust,
} from "@jbcode/pi-shared/child-session";
import {
runAgent,
type AgentOutcome,
type RunAgentOptions,
type ThinkingLevel,
type WorkflowModel,
} from "../_workflows/runner.ts";
import { STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION } from "../_workflows/prompt.ts";
import type {
AssignmentExecutionError,
AssignmentExecutionMetadata,
AssignmentExecutionResult,
AssignmentRequest,
AssignmentExecutor,
} from "./engine.ts";
import { GitStateProbe, MutationScope } from "./workspace.ts";
const PROCESS_MAX_ASSIGNMENTS = 32;
interface ScheduledAssignment {
runId: string;
runLimit: number;
exclusive: boolean;
signal: AbortSignal;
start: () => void;
reject: (error: Error) => void;
state: "queued" | "reserved" | "running" | "settled";
onAbort: () => void;
}
function abortError(signal: AbortSignal) {
return signal.reason instanceof Error
? signal.reason
: new Error("Assignment was cancelled");
}
/** Process-wide capacity with synchronous reservation and cancellation-safe release. */
export class ProcessAssignmentScheduler {
private ceiling = PROCESS_MAX_ASSIGNMENTS;
private inUse = 0;
private running = 0;
private readonly runUsage = new Map<string, number>();
private readonly exclusiveRuns = new Set<string>();
private readonly queue: ScheduledAssignment[] = [];
lowerCeiling(ceiling: number) {
if (!Number.isInteger(ceiling) || ceiling < 1 || ceiling > PROCESS_MAX_ASSIGNMENTS) {
throw new Error(`Assignment ceiling must be between 1 and ${PROCESS_MAX_ASSIGNMENTS}`);
}
this.ceiling = Math.min(this.ceiling, ceiling);
this.drain();
}
schedule<T>(options: {
runId: string;
runLimit: number;
signal: AbortSignal;
exclusive?: boolean;
onReserved?: () => Promise<void>;
task: (signal: AbortSignal) => Promise<T>;
}): Promise<T> {
if (!Number.isInteger(options.runLimit) || options.runLimit < 1) {
return Promise.reject(new Error("Workflow Run assignment limit must be positive"));
}
if (options.signal.aborted) return Promise.reject(abortError(options.signal));
return new Promise<T>((resolve, reject) => {
const entry: ScheduledAssignment = {
runId: options.runId,
runLimit: options.runLimit,
signal: options.signal,
exclusive: options.exclusive ?? false,
state: "queued",
reject,
onAbort: () => {
if (entry.state !== "queued") return;
entry.state = "settled";
const index = this.queue.indexOf(entry);
if (index >= 0) this.queue.splice(index, 1);
options.signal.removeEventListener("abort", entry.onAbort);
reject(abortError(options.signal));
},
start: () => {
queueMicrotask(async () => {
if (entry.state !== "reserved") return;
entry.state = "running";
this.running++;
try {
if (options.signal.aborted) throw abortError(options.signal);
await options.onReserved?.();
if (options.signal.aborted) throw abortError(options.signal);
resolve(await options.task(options.signal));
} catch (error) {
reject(error);
} finally {
this.running--;
this.release(entry);
}
});
},
};
options.signal.addEventListener("abort", entry.onAbort, { once: true });
this.queue.push(entry);
this.drain();
});
}
snapshot() {
return {
ceiling: this.ceiling,
inUse: this.inUse,
running: this.running,
queued: this.queue.filter((entry) => entry.state === "queued").length,
};
}
private drain() {
while (this.inUse < this.ceiling) {
const index = this.queue.findIndex((entry, entryIndex) => {
if (entry.state !== "queued") return false;
const usage = this.runUsage.get(entry.runId) ?? 0;
if (entry.exclusive) return usage === 0;
if (this.exclusiveRuns.has(entry.runId) || usage >= entry.runLimit) {
return false;
}
return !this.queue
.slice(0, entryIndex)
.some(
(queued) =>
queued.state === "queued" &&
queued.runId === entry.runId &&
queued.exclusive,
);
});
if (index < 0) return;
const entry = this.queue.splice(index, 1)[0];
if (!entry || entry.signal.aborted) {
entry?.onAbort();
continue;
}
entry.state = "reserved";
this.inUse++;
this.runUsage.set(entry.runId, (this.runUsage.get(entry.runId) ?? 0) + 1);
if (entry.exclusive) this.exclusiveRuns.add(entry.runId);
entry.start();
}
}
private release(entry: ScheduledAssignment) {
if (entry.state === "settled") return;
entry.state = "settled";
entry.signal.removeEventListener("abort", entry.onAbort);
this.inUse--;
if (entry.exclusive) this.exclusiveRuns.delete(entry.runId);
const remaining = (this.runUsage.get(entry.runId) ?? 1) - 1;
if (remaining === 0) this.runUsage.delete(entry.runId);
else this.runUsage.set(entry.runId, remaining);
this.drain();
}
}
export const processAssignmentScheduler = new ProcessAssignmentScheduler();
export function resolveWorkflowModel(
registry: ExtensionContext["modelRegistry"],
profile: { provider?: string; model?: string },
inherited: WorkflowModel | undefined,
): Model<Api> | undefined {
if (profile.provider && !profile.model) {
throw new Error(`Agent Profile provider ${profile.provider} requires a model`);
}
if (!profile.model) {
if (!inherited) return undefined;
const found = registry.find(inherited.provider, inherited.id);
if (!found) {
throw new Error(
`Unknown inherited model ${inherited.provider}/${inherited.id}`,
);
}
return found;
}
if (profile.provider) {
const found = registry.find(profile.provider, profile.model);
if (!found) throw new Error(`Unknown model ${profile.provider}/${profile.model}`);
return found;
}
const slash = profile.model.indexOf("/");
if (slash > 0) {
const provider = profile.model.slice(0, slash);
const model = profile.model.slice(slash + 1);
const found = registry.find(provider, model);
if (!found) throw new Error(`Unknown model ${profile.model}`);
return found;
}
if (inherited) {
const found = registry.find(inherited.provider, profile.model);
if (found) return found;
}
const matches = registry.getAll().filter((model) => model.id === profile.model);
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
throw new Error(
`Model ${profile.model} exists in multiple providers; use provider/model`,
);
}
throw new Error(`Unknown model ${profile.model}`);
}
const DISCOVERY_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"questions",
"candidatePaths",
"languages",
"tags",
"riskTags",
],
properties: {
questions: {
type: "array",
maxItems: 20,
items: {
type: "object",
additionalProperties: false,
required: ["id", "prompt"],
properties: {
id: { type: "string", maxLength: 160 },
prompt: { type: "string", maxLength: 2000 },
},
},
},
candidatePaths: { type: "array", items: { type: "string" } },
languages: { type: "array", items: { type: "string" } },
tags: { type: "array", items: { type: "string" } },
riskTags: { type: "array", items: { type: "string" } },
},
} as const;
const DESIGN_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"plan",
"uiImpact",
"uiImpactEvidence",
"affectedUiSurfaces",
"assignments",
"acceptanceCriteria",
"verificationCommands",
],
properties: {
plan: { type: "string" },
uiImpact: { enum: ["none", "minor", "non-trivial"] },
uiImpactEvidence: { type: "array", items: { type: "string" } },
affectedUiSurfaces: { type: "array", items: { type: "string" } },
acceptanceCriteria: { type: "array", items: { type: "string" } },
verificationCommands: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["executable", "args", "ownedPaths", "kind"],
properties: {
executable: { type: "string" },
args: { type: "array", items: { type: "string" } },
ownedPaths: { type: "array", items: { type: "string" } },
kind: {
enum: ["test", "typecheck", "lint", "build", "browser", "generation"],
},
},
},
},
assignments: {
type: "array",
minItems: 3,
items: {
type: "object",
additionalProperties: false,
required: ["role", "profileName", "rationale", "mutates", "ownedPaths"],
properties: {
role: { enum: ["prototype", "implement", "verify", "review"] },
profileName: { type: "string" },
rationale: { type: "string" },
mutates: { type: "boolean" },
ownedPaths: { type: "array", items: { type: "string" } },
},
},
},
},
} as const;
const PROTOTYPE_SCHEMA = {
type: "object",
additionalProperties: false,
required: [
"path",
"route",
"structuralRationale",
"responsiveChecks",
"browserEvidence",
"launchInstructions",
],
properties: {
path: { type: "string" },
route: { type: "string" },
structuralRationale: { type: "string" },
responsiveChecks: { type: "array", items: { type: "string" } },
browserEvidence: { type: "array", items: { type: "string" } },
launchInstructions: { type: "string" },
},
} as const;
const IMPLEMENTATION_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["summary", "changedPaths", "focusedTests"],
properties: {
summary: { type: "string" },
changedPaths: { type: "array", items: { type: "string" } },
focusedTests: { type: "array", items: { type: "string" } },
},
} as const;
const VERIFICATION_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["succeeded", "focusedBehavioralTests", "summary"],
properties: {
succeeded: { type: "boolean" },
focusedBehavioralTests: { type: "boolean" },
summary: { type: "string" },
},
} as const;
const REVIEW_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["specification", "standards"],
properties: {
specification: { $ref: "#/$defs/axis" },
standards: { $ref: "#/$defs/axis" },
},
$defs: {
axis: {
type: "object",
additionalProperties: false,
required: ["verdict", "findings"],
properties: {
verdict: { enum: ["approved", "changes_requested"] },
findings: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["severity", "message"],
properties: {
severity: { type: "string" },
message: { type: "string" },
path: { type: "string" },
},
},
},
},
},
},
} as const;
function schemaForRole(role: AssignmentRequest["role"]) {
if (role === "discover") return DISCOVERY_SCHEMA;
if (role === "design") return DESIGN_SCHEMA;
if (role === "prototype") return PROTOTYPE_SCHEMA;
if (role === "verify") return VERIFICATION_SCHEMA;
if (role === "review") return REVIEW_SCHEMA;
return IMPLEMENTATION_SCHEMA;
}
function pagedTextTool(options: {
name: "workflow_diff" | "workflow_plan";
label: string;
description: string;
contents: string;
}) {
const characters = [...options.contents];
return defineTool({
name: options.name,
label: options.label,
description: options.description,
parameters: Type.Object({
offset: Type.Optional(Type.Integer({ minimum: 0 })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 48 * 1024 })),
}),
async execute(_id, params) {
const offset = params.offset ?? 0;
const limit = params.limit ?? 48 * 1024;
return {
content: [
{
type: "text" as const,
text: characters.slice(offset, offset + limit).join(""),
},
],
details: { offset, limit, totalCharacters: characters.length },
};
},
});
}
function readCompletePagedText(
transcript: AssignmentExecutionMetadata["transcript"],
toolName: "workflow_diff" | "workflow_plan",
totalCharacters: number,
) {
if (totalCharacters === 0) return true;
const entries = transcript.filter(
(
entry,
): entry is {
role: string;
name?: string;
text: string;
toolCallId?: string;
isError?: boolean;
} =>
typeof entry === "object" &&
entry !== null &&
"role" in entry &&
typeof entry.role === "string" &&
"text" in entry &&
typeof entry.text === "string",
);
const successfulCallIds = new Set(
entries
.filter(
(entry) =>
entry.role === "toolResult" &&
entry.name === toolName &&
!entry.isError &&
typeof entry.toolCallId === "string",
)
.map((entry) => entry.toolCallId!),
);
const intervals = entries
.filter(
(entry) =>
entry.role === "tool" &&
typeof entry.toolCallId === "string" &&
successfulCallIds.has(entry.toolCallId) &&
entry.name === toolName,
)
.map((entry) => {
try {
const input = JSON.parse(entry.text) as { offset?: unknown; limit?: unknown };
const offset = input.offset === undefined ? 0 : input.offset;
const limit = input.limit === undefined ? 48 * 1024 : input.limit;
if (
typeof offset !== "number" ||
!Number.isInteger(offset) ||
offset < 0 ||
typeof limit !== "number" ||
!Number.isInteger(limit) ||
limit < 1 ||
limit > 48 * 1024
) {
return [0, 0] as const;
}
return [offset, Math.min(totalCharacters, offset + limit)] as const;
} catch {
return [0, 0] as const;
}
})
.sort((left, right) => left[0] - right[0]);
let covered = 0;
for (const [start, end] of intervals) {
if (start > covered) return false;
covered = Math.max(covered, end);
if (covered >= totalCharacters) return true;
}
return false;
}
function assignmentPrompt(request: AssignmentRequest) {
const plannerCandidates = request.plannerCandidates?.map((entry) => ({
role: entry.role,
candidates: entry.candidates
.filter((candidate) => candidate.eligible)
.map((candidate) => ({
name: candidate.profileName,
digest: candidate.profileDigest,
specificity: candidate.specificity,
})),
}));
return [
`Assignment role: ${request.role}`,
`Workflow request: ${request.request}`,
`Repository facts: ${JSON.stringify(request.facts)}`,
`Accepted decisions: ${JSON.stringify(request.decisions.map((item) => item.value))}`,
...(plannerCandidates
? [`Eligible planner candidates: ${JSON.stringify(plannerCandidates)}`]
: []),
...(request.approvedPlan
? request.role === "verify" || request.role === "review"
? [
`Approved plan: ${[...request.approvedPlan].length} characters available through workflow_plan. Read the complete plan.`,
]
: [`Approved plan:\n${request.approvedPlan}`]
: []),
...(request.verificationEvidence
? [
`Persisted verification evidence: ${JSON.stringify(request.verificationEvidence)}`,
]
: []),
...(request.reviewDiff
? [
`Review diff: ${[...request.reviewDiff].length} characters available through workflow_diff. Review the complete scope.`,
]
: []),
"Follow the active Agent Profile instructions. Return only through structured_output.",
].join("\n\n");
}
interface ChildResources {
loader: DefaultResourceLoader;
settingsManager: SettingsManager;
}
export interface PiAssignmentExecutorOptions {
modelRegistry: ExtensionContext["modelRegistry"];
parentCwd: string;
parentProjectTrusted: boolean;
parentModel?: WorkflowModel;
parentThinkingLevel: ThinkingLevel;
concurrencyCeiling: number;
runConcurrency: number;
scheduler?: ProcessAssignmentScheduler;
runAgent?: (options: RunAgentOptions) => Promise<AgentOutcome>;
createResources?: (options: {
cwd: string;
projectTrusted: boolean;
appendSystemPrompt: string[];
}) => Promise<ChildResources>;
resolveProjectTrust?: typeof resolveStandaloneChildProjectTrust;
}
export class PiAssignmentExecutor implements AssignmentExecutor {
readonly reservesCapacity = true;
private readonly options: PiAssignmentExecutorOptions;
private readonly scheduler: ProcessAssignmentScheduler;
constructor(options: PiAssignmentExecutorOptions) {
this.options = options;
this.scheduler = options.scheduler ?? processAssignmentScheduler;
this.scheduler.lowerCeiling(options.concurrencyCeiling);
}
async run(request: AssignmentRequest): Promise<AssignmentExecutionResult> {
const model = resolveWorkflowModel(
this.options.modelRegistry,
request.profile,
this.options.parentModel,
) as WorkflowModel | undefined;
const thinkingLevel =
(request.profile.effort as ThinkingLevel | undefined) ??
this.options.parentThinkingLevel;
const resolveTrust =
this.options.resolveProjectTrust ?? resolveStandaloneChildProjectTrust;
const projectTrusted = resolveTrust({
parentCwd: this.options.parentCwd,
childCwd: request.cwd,
parentTrusted: this.options.parentProjectTrusted,
});
const createResources = this.options.createResources ?? createChildResources;
const resources = await createResources({
cwd: request.cwd,
projectTrusted,
appendSystemPrompt: [
request.profile.instructions,
STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
],
});
const execute = this.options.runAgent ?? runAgent;
const mutationScope = request.mutates
? await MutationScope.create(request.cwd, request.ownedPaths ?? [])
: undefined;
const gitProbe =
mutationScope || request.captureGitState ? new GitStateProbe() : undefined;
const evidenceTools = [
...(request.reviewDiff
? [
pagedTextTool({
name: "workflow_diff",
label: "Workflow Diff",
description:
"Read the complete review diff in bounded character ranges.",
contents: request.reviewDiff,
}),
]
: []),
...(request.approvedPlan &&
(request.role === "verify" || request.role === "review")
? [
pagedTextTool({
name: "workflow_plan",
label: "Workflow Plan",
description:
"Read the complete approved plan in bounded character ranges.",
contents: request.approvedPlan,
}),
]
: []),
];
const allowedReadTools = new Set(
request.allowedReadTools ?? ["read", "grep", "find", "ls"],
);
const auditedReadTools = [
createReadToolDefinition(request.cwd),
createGrepToolDefinition(request.cwd),
createFindToolDefinition(request.cwd),
createLsToolDefinition(request.cwd),
].filter((tool) => allowedReadTools.has(tool.name));
const mutationTools = mutationScope
? [
createEditToolDefinition(request.cwd, {
operations: {
async readFile(absolutePath) {
return readFile(await mutationScope.assertWritePath(absolutePath));
},
async writeFile(absolutePath, content) {
await mutationScope.writeFile(absolutePath, content);
},
async access(absolutePath) {
await access(await mutationScope.assertWritePath(absolutePath));
},
},
}),
createWriteToolDefinition(request.cwd, {
operations: {
async writeFile(absolutePath, content) {
await mutationScope.writeFile(absolutePath, content);
},
async mkdir(directory) {
await mutationScope.mkdir(directory);
},
},
}),
]
: [];
const removalTools =
mutationScope && request.role === "remediate"
? [
defineTool({
name: "workflow_remove",
label: "Remove Owned Prototype Path",
description:
"Remove a prototype-only file or directory inside Assignment ownership.",
parameters: Type.Object({ path: Type.String() }),
async execute(_id, params) {
await mutationScope.remove(params.path);
return {
content: [
{ type: "text" as const, text: `Removed ${params.path}` },
],
details: { path: params.path },
};
},
}),
]
: [];
return this.scheduler.schedule({
runId: request.runId,
runLimit: request.runConcurrency ?? this.options.runConcurrency,
signal: request.signal,
exclusive: request.mutates,
onReserved: request.onReserved,
task: async (signal) => {
let created: AssignmentExecutionMetadata | undefined;
const gitBefore = gitProbe
? await gitProbe.capture(mutationScope?.root ?? request.cwd)
: undefined;
const outcome = await execute({
prompt: assignmentPrompt(request),
schema: schemaForRole(request.role),
model,
thinkingLevel,
cwd: request.cwd,
loader: resources.loader,
settingsManager: resources.settingsManager,
modelRegistry: this.options.modelRegistry,
signal,
tools: request.mutates
? [
...(request.profile.tools ?? []).filter((tool) =>
(["edit", "write"].includes(tool) ||
allowedReadTools.has(tool)),
),
...evidenceTools.map((tool) => tool.name),
...removalTools.map((tool) => tool.name),
"structured_output",
]
: [
...(request.profile.tools ?? []).filter((tool) =>
allowedReadTools.has(tool),
),
...evidenceTools.map((tool) => tool.name),
"structured_output",
],
customTools: [
...auditedReadTools,
...mutationTools,
...removalTools,
...evidenceTools,
],
onSessionCreated(metadata) {
created = {
childSessionId: metadata.childSessionId,
provider: metadata.provider,
model: metadata.model,
effort: metadata.thinkingLevel,
contextCapacity: undefined,
transcript: [],
};
},
});
const gitAfter = gitProbe
? await gitProbe.capture(mutationScope?.root ?? request.cwd)
: undefined;
let safetyError: Error | undefined;
if (
request.role === "review" &&
request.reviewDiff !== undefined &&
!readCompletePagedText(
outcome.transcript,
"workflow_diff",
[...request.reviewDiff].length,
)
) {
safetyError = new Error(
"Review Assignment did not read the complete workflow diff",
);
}
if (
!safetyError &&
request.approvedPlan !== undefined &&
(request.role === "verify" || request.role === "review") &&
!readCompletePagedText(
outcome.transcript,
"workflow_plan",
[...request.approvedPlan].length,
)
) {
safetyError = new Error(
"Assignment did not read the complete approved workflow plan",
);
}
if (!safetyError && gitProbe && gitBefore && gitAfter) {
try {
gitProbe.assertIdentity(gitBefore, gitAfter);
const touched = gitProbe.changedSince(gitBefore, gitAfter);
const outOfScope = mutationScope
? touched.filter((changed) => !mutationScope.owns(changed))
: touched;
if (outOfScope.length > 0) {
throw new Error(
request.captureGitState && !mutationScope
? `Read-only Assignment changed the repository: ${outOfScope.join(", ")}`
: `Assignment changed paths outside ownership: ${outOfScope.join(", ")}`,
);
}
} catch (error) {
safetyError = error instanceof Error ? error : new Error(String(error));
}
}
const metadata: AssignmentExecutionMetadata = {
childSessionId: outcome.childSessionId ?? created?.childSessionId,
provider: outcome.provider ?? created?.provider,
model: outcome.model ?? created?.model,
effort: outcome.thinkingLevel ?? created?.effort ?? thinkingLevel,
contextCapacity: outcome.contextWindow,
transcript: outcome.transcript,
gitBefore,
gitAfter,
};
if (safetyError || !outcome.ok) {
const error = new Error(
safetyError?.message ?? outcome.error ?? "Pi Assignment failed",
) as AssignmentExecutionError;
error.assignmentMetadata = metadata;
throw error;
}
return {
kind: "workflow-assignment-result",
output: outcome.structured,
metadata,
};
},
});
}
}
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { registerWorkflowRecovery } from "./index.ts";
import type { ContextTransitionRuntime } from "./context-transition.ts";
test("session startup invokes persisted Workflow recovery", async () => {
let sessionStart:
| ((event: { reason: string }, ctx: ExtensionContext) => Promise<void>)
| undefined;
const pi = {
on(event: string, handler: typeof sessionStart) {
if (event === "session_start") sessionStart = handler;
},
} as unknown as ExtensionAPI;
let recoveries = 0;
registerWorkflowRecovery(pi, {
store: {} as never,
engineForContext: async () =>
({
async recover() {
recoveries++;
return [];
},
}) as never,
} as ContextTransitionRuntime);
assert.ok(sessionStart);
await sessionStart(
{ reason: "startup" },
{
ui: { notify: () => {} },
} as unknown as ExtensionContext,
);
assert.equal(recoveries, 1);
});
+132
View File
@@ -0,0 +1,132 @@
import * as path from "node:path";
import {
getAgentDir,
type ExtensionAPI,
type ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { loadWorkflowConfig } from "./config.ts";
import type { WorkflowRun } from "./domain.ts";
import { openWorkflowDashboard } from "./dashboard.ts";
import {
registerWorkflowContextTransitions,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import { WorkflowEngine } from "./engine.ts";
import {
PiAssignmentExecutor,
type ProcessAssignmentScheduler,
} from "./execution.ts";
import { loadAgentProfiles } from "./profiles.ts";
import {
reconcileWorkflowDeliveries,
registerWorkflowNotifications,
} from "./notifications.ts";
import { runScriptSandbox } from "./script-runtime.ts";
import { WorkflowStore } from "./store.ts";
import { registerWorkflowTool } from "./tool.ts";
import {
refreshWorkflowProgress,
registerWorkflowProgress,
} from "./progress.ts";
import {
ApprovedHostCommandExecutor,
FilesystemPrototypeValidator,
GitDiffProvider,
GitWorkspaceManager,
} from "./workspace.ts";
import type { ThinkingLevel, WorkflowModel } from "../_workflows/runner.ts";
export interface WorkflowExtensionOptions {
stateRoot?: string;
scheduler?: ProcessAssignmentScheduler;
afterRunPersist?: (
ctx: ExtensionContext,
run: WorkflowRun,
) => void | Promise<void>;
}
export function createWorkflowRuntime(
options: WorkflowExtensionOptions = {},
): ContextTransitionRuntime {
const store = new WorkflowStore(
options.stateRoot ?? path.join(getAgentDir(), "workflows"),
);
return {
store,
async engineForContext(ctx: ExtensionContext) {
const config = await loadWorkflowConfig({
cwd: ctx.cwd,
projectTrusted: ctx.isProjectTrusted(),
});
const catalog = await loadAgentProfiles({
cwd: ctx.cwd,
projectTrusted: ctx.isProjectTrusted(),
standardAgents: config.standardAgents,
});
const executor = new PiAssignmentExecutor({
modelRegistry: ctx.modelRegistry,
parentCwd: ctx.cwd,
parentProjectTrusted: ctx.isProjectTrusted(),
parentModel: ctx.model as WorkflowModel | undefined,
parentThinkingLevel: (ctx.thinkingLevel ?? "off") as ThinkingLevel,
concurrencyCeiling: config.concurrency.ceiling,
runConcurrency: config.concurrency.default,
...(options.scheduler ? { scheduler: options.scheduler } : {}),
});
return new WorkflowEngine({
store,
workspace: new GitWorkspaceManager(),
executor,
hostCommands: new ApprovedHostCommandExecutor(),
reviewDiff: new GitDiffProvider(),
scriptRuntime: { run: runScriptSandbox },
prototypeValidator: new FilesystemPrototypeValidator(),
sourceRoot: ctx.cwd,
parentSessionId: ctx.sessionManager.getSessionId(),
effectiveConfig: config,
profiles: catalog.profiles,
...(options.afterRunPersist
? {
afterRunPersist: (run) => options.afterRunPersist!(ctx, run),
}
: {}),
});
},
};
}
export function registerWorkflowRecovery(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
pi.on("session_start", async (_event, ctx) => {
try {
const engine = await runtime.engineForContext(ctx);
await engine.recover();
} catch (error) {
ctx.ui.notify(
`workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
}
});
}
export default function workflowExtension(pi: ExtensionAPI) {
let runtime: ContextTransitionRuntime;
runtime = createWorkflowRuntime({
afterRunPersist: async (ctx) => {
await Promise.all([
reconcileWorkflowDeliveries(pi, runtime, ctx),
refreshWorkflowProgress(runtime, ctx),
]);
},
});
registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
openWorkflowDashboard(pi, runtime, ctx, args || undefined),
);
registerWorkflowTool(pi, runtime);
registerWorkflowRecovery(pi, runtime);
registerWorkflowNotifications(pi, runtime);
registerWorkflowProgress(pi, runtime);
}
@@ -0,0 +1,289 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import {
DEFAULT_WORKFLOW_CONFIG,
type WorkflowRun,
} from "./domain.ts";
import {
reconcileWorkflowDeliveries,
workflowNotificationType,
} from "./notifications.ts";
import { WorkflowStore } from "./store.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-delivery-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
function workflow(background = false): WorkflowRun {
const gateId = "gate-1";
return {
runId: "run-1",
revision: 0,
mode: "feature",
status: "waiting",
background,
request: "Build search",
featureName: "search",
parentSessionIds: ["session-1"],
lease: {
processId: 100,
activeParentSessionId: "session-1",
heartbeatAt: 1,
},
workspace: {
state: "ready",
sourceRoot: "/repo",
root: "/repo/.worktrees/run-1",
branch: "feature/search",
baseHead: "abc123",
},
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "design",
taskKind: "feature",
requestTerms: ["search"],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [],
gates: [
{
id: gateId,
kind: "plan-approval",
status: "open",
revision: 0,
openedAt: 1,
},
],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [
{
key: `gate:run-1:${gateId}:0`,
state: "pending",
createdAt: 1,
},
],
commandReceipts: [],
uiImpactEvidence: [],
affectedUiSurfaces: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
};
}
function deliveryHarness(entries: unknown[], persistImmediately = true) {
const sent: Array<{ message: unknown; options: unknown }> = [];
const pi = {
sendMessage(message: unknown, options: unknown) {
sent.push({ message, options });
if (persistImmediately) {
const record = message as Record<string, unknown>;
entries.push({
type: "message",
message: {
role: "custom",
customType: record.customType,
details: record.details,
},
});
}
},
} as unknown as Pick<ExtensionAPI, "sendMessage">;
const ctx = {
sessionManager: {
getSessionId: () => "session-1",
getEntries: () => entries,
},
} as unknown as ExtensionContext;
return { pi, ctx, sent };
}
test("delivery receipt closes the send/save crash window exactly once", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
await store.createRun(workflow());
const entries: unknown[] = [];
const harness = deliveryHarness(entries);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.equal(harness.sent.length, 1);
const message = harness.sent[0]?.message as Record<string, unknown>;
assert.equal(message.customType, workflowNotificationType);
assert.match(String(message.content), /workflow search is waiting/);
assert.equal(
(message.details as Record<string, unknown>).runId,
"run-1",
);
const delivered = await store.loadRun("run-1");
assert.equal(delivered.deliveries[0]?.state, "sent");
assert.equal(delivered.revision, 0);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.equal(harness.sent.length, 1);
});
});
test("queued foreground delivery suppresses immediate duplicates and retries a missing receipt", async () => {
await withTempDir(async (directory) => {
const originalNow = Date.now;
let now = 1_000;
Date.now = () => now;
try {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow();
await store.createRun(run);
const entries: unknown[] = [];
const harness = deliveryHarness(entries, false);
const runtime = {
store,
engineForContext: () => Promise.reject(new Error("unused")),
};
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
now++;
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
assert.equal(harness.sent.length, 1);
assert.deepEqual(harness.sent[0]?.options, { deliverAs: "steer" });
now += 30_000;
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
assert.equal(harness.sent.length, 2);
const sentMessage = harness.sent[1]?.message as Record<string, unknown>;
entries.push({
type: "message",
message: {
role: "custom",
customType: sentMessage.customType,
details: sentMessage.details,
},
});
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
assert.equal(harness.sent.length, 2);
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
} finally {
Date.now = originalNow;
}
});
});
test("obsolete Gate deliveries settle without sending contradictory notices", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow();
run.status = "running";
run.gates[0]!.status = "satisfied";
await store.createRun(run);
const harness = deliveryHarness([]);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.equal(harness.sent.length, 0);
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
});
});
test("an existing receiver receipt marks pending delivery without resending", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow(true);
await store.createRun(run);
const entries = [
{
type: "message",
message: {
role: "custom",
customType: workflowNotificationType,
details: { deliveryKey: run.deliveries[0]?.key },
},
},
];
const harness = deliveryHarness(entries);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.equal(harness.sent.length, 0);
assert.equal(
(await store.loadRun("run-1")).deliveries[0]?.state,
"sent",
);
});
});
test("terminal deliveries include the persisted report and settle once", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow(true);
run.status = "completed";
run.gates = [];
run.reportArtifact = {
path: "/state/report.md",
digest: "digest",
revision: 4,
};
run.deliveries = [
{
key: "terminal:run-1:completed:4",
state: "pending",
createdAt: 4,
},
];
await store.createRun(run);
const harness = deliveryHarness([]);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.equal(harness.sent.length, 1);
assert.match(
String((harness.sent[0]?.message as Record<string, unknown>).content),
/workflow search finished with status completed.*Report: \/state\/report.md/,
);
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
});
});
test("background deliveries request a follow-up turn", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
await store.createRun(workflow(true));
const harness = deliveryHarness([]);
await reconcileWorkflowDeliveries(
harness.pi,
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
harness.ctx,
);
assert.deepEqual(harness.sent[0]?.options, {
deliverAs: "followUp",
triggerTurn: true,
});
});
});
@@ -0,0 +1,209 @@
import { randomUUID } from "node:crypto";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { WorkflowRun } from "./domain.ts";
import type { ContextTransitionRuntime } from "./context-transition.ts";
import { deriveRunLabel } from "./run-label.ts";
const NOTIFICATION_TYPE = "workflow-notification";
const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`;
const ACTIVE_DELIVERIES = new Map<
string,
{ scheduledAt: number; runId: string }
>();
const DELIVERY_RETRY_MS = 30_000;
function receiptKeys(ctx: ExtensionContext) {
const keys = new Set<string>();
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type !== "message") continue;
const message = entry.message as unknown as Record<string, unknown>;
if (message.role !== "custom" || message.customType !== NOTIFICATION_TYPE) {
continue;
}
const details = message.details;
if (!details || typeof details !== "object") continue;
const key = (details as Record<string, unknown>).deliveryKey;
if (typeof key === "string") keys.add(key);
}
return keys;
}
function deliveryIsCurrent(run: WorkflowRun, key: string) {
if (key.startsWith("gate:")) {
const gate = [...run.gates]
.reverse()
.find((candidate) => key.includes(`:${candidate.id}:`));
const activeGate = [...run.gates]
.reverse()
.find((candidate) => candidate.status === "open");
return Boolean(gate && gate.status === "open" && activeGate?.id === gate.id);
}
if (key.startsWith("terminal:")) {
return key.includes(`:${run.status}:`) &&
(run.status === "completed" ||
run.status === "failed" ||
run.status === "aborted");
}
return false;
}
function deliveryText(run: WorkflowRun, key: string) {
const label = deriveRunLabel(run);
if (key.startsWith("gate:")) {
const gate = [...run.gates]
.reverse()
.find((candidate) => key.includes(`:${candidate.id}:`));
return gate
? `workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
: `workflow ${label} is waiting for a Gate Decision.`;
}
const report = run.reportArtifact?.path
? ` Report: ${run.reportArtifact.path}`
: "";
return `workflow ${label} finished with status ${run.status}.${report}`;
}
function belongsToSession(run: WorkflowRun, sessionId: string) {
const activeSession =
run.lease?.activeParentSessionId ?? run.parentSessionIds.at(-1);
return activeSession === sessionId;
}
export async function reconcileWorkflowDeliveries(
pi: Pick<ExtensionAPI, "sendMessage">,
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
) {
const sessionId = ctx.sessionManager.getSessionId();
if (!sessionId) return;
let receipts = receiptKeys(ctx);
for (const run of await runtime.store.listRuns()) {
if (!belongsToSession(run, sessionId)) continue;
if (
(run.status === "completed" ||
run.status === "failed" ||
run.status === "aborted") &&
run.reportArtifact
) {
await runtime.store.ensureResult(run);
}
for (const delivery of run.deliveries) {
if (delivery.state === "sent") continue;
if (!deliveryIsCurrent(run, delivery.key)) {
ACTIVE_DELIVERIES.delete(delivery.key);
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
await runtime.store.markDeliverySent(
run.runId,
delivery.key,
Date.now(),
);
continue;
}
if (receipts.has(delivery.key)) {
ACTIVE_DELIVERIES.delete(delivery.key);
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
await runtime.store.markDeliverySent(
run.runId,
delivery.key,
Date.now(),
);
continue;
}
const active = ACTIVE_DELIVERIES.get(delivery.key);
if (
active &&
Date.now() - active.scheduledAt < DELIVERY_RETRY_MS
) {
continue;
}
const claimedAt = Date.now();
const claimed = await runtime.store.claimDelivery(
run.runId,
delivery.key,
DELIVERY_OWNER_TOKEN,
claimedAt,
);
if (!claimed) continue;
ACTIVE_DELIVERIES.set(delivery.key, {
scheduledAt: claimedAt,
runId: run.runId,
});
try {
pi.sendMessage(
{
customType: NOTIFICATION_TYPE,
content: deliveryText(run, delivery.key),
display: true,
details: {
deliveryKey: delivery.key,
runId: run.runId,
revision: run.revision,
status: run.status,
reportPath: run.reportArtifact?.path,
},
},
run.background
? { deliverAs: "followUp", triggerTurn: true }
: { deliverAs: "steer" },
);
receipts = receiptKeys(ctx);
if (receipts.has(delivery.key)) {
ACTIVE_DELIVERIES.delete(delivery.key);
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
await runtime.store.markDeliverySent(
run.runId,
delivery.key,
Date.now(),
);
}
} catch (error) {
ACTIVE_DELIVERIES.delete(delivery.key);
await runtime.store.releaseDeliveryClaim(
run.runId,
delivery.key,
DELIVERY_OWNER_TOKEN,
);
throw error;
}
}
}
}
export function registerWorkflowNotifications(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
const reconcile = async (ctx: ExtensionContext) => {
try {
await reconcileWorkflowDeliveries(pi, runtime, ctx);
} catch (error) {
ctx.ui.notify(
`workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
}
};
pi.on("session_start", async (_event, ctx) => reconcile(ctx));
pi.on("agent_settled", async (_event, ctx) => reconcile(ctx));
pi.on("tool_result", async (event, ctx) => {
if (event.toolName === "workflow") await reconcile(ctx);
});
pi.on("session_shutdown", async () => {
const active = [...ACTIVE_DELIVERIES.entries()];
ACTIVE_DELIVERIES.clear();
await Promise.all(
active.map(([key, delivery]) =>
runtime.store.releaseDeliveryClaim(
delivery.runId,
key,
DELIVERY_OWNER_TOKEN,
),
),
);
});
}
export const workflowNotificationType = NOTIFICATION_TYPE;
@@ -0,0 +1,20 @@
{
"name": "@jbcode/pi-workflow",
"version": "0.1.0",
"private": true,
"description": "Private workspace for the deferred Pi workflow extension migration.",
"type": "module",
"license": "MIT",
"engines": { "node": ">=22.19.0" },
"keywords": ["pi-package", "pi-extension", "workflow"],
"pi": { "extensions": ["./index.ts"] },
"dependencies": {
"@jbcode/pi-shared": "workspace:^"
},
"peerDependencies": {
"@earendil-works/pi-ai": "catalog:pi-peer",
"@earendil-works/pi-coding-agent": "catalog:pi-peer",
"@earendil-works/pi-tui": "catalog:pi-peer",
"typebox": "catalog:pi-peer"
}
}
@@ -0,0 +1,699 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import type { AgentProfile, WorkflowFacts } from "./domain.ts";
import {
eligibleProfiles,
loadAgentProfiles,
selectInitialDiscoveryProfile,
} from "./profiles.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-profiles-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
function profileDocument(options: {
name: string;
description?: string;
roles?: string;
selection?: string;
extra?: string;
body?: string;
}) {
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\nroles: ${options.roles ?? "[discover]"}\n${options.extra ?? ""}selection:\n${options.selection ?? " stages: [discover]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n"}---\n\n${options.body ?? "Follow the assignment."}\n`;
}
function standardAgentDocument(options: {
name: string;
description?: string;
model?: string;
tools?: string;
thinking?: string;
body?: string;
}) {
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\n${options.model ? `model: ${options.model}\n` : ""}${options.tools ? `tools: ${options.tools}\n` : ""}${options.thinking ? `thinking: ${options.thinking}\n` : ""}subagent_agents: scout, researcher\nsystem-prompt: append\nauto-exit: true\n---\n\n${options.body ?? "Follow the standard assignment."}\n`;
}
async function writeProfile(
root: string,
file: string,
options: Parameters<typeof profileDocument>[0],
) {
await mkdir(root, { recursive: true });
await writeFile(path.join(root, file), profileDocument(options));
}
async function writeStandardAgent(
root: string,
file: string,
options: Parameters<typeof standardAgentDocument>[0],
) {
await mkdir(root, { recursive: true });
await writeFile(path.join(root, file), standardAgentDocument(options));
}
test("bundled profiles form a complete deterministic catalog", async () => {
await withTempDir(async (directory) => {
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
projectTrusted: false,
});
assert.deepEqual(
catalog.profiles.map((profile) => profile.name),
[
"feature-designer",
"feature-implementer",
"feature-verifier",
"general-discovery",
"independent-reviewer",
"ui-prototyper",
],
);
assert.equal(catalog.diagnostics.length, 0);
assert.ok(
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
);
});
});
test("standard package, global, and trusted project agents use role overlays and precedence", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const agentDir = path.join(directory, "agent");
const globalRoot = path.join(agentDir, "agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", {
name: "general-discovery",
});
await writeStandardAgent(packageRoot, "scout.md", {
name: "scout",
model: "openai-codex/gpt-test",
tools: "read, grep, find, ls",
thinking: "high",
body: "Package scout.",
});
await writeStandardAgent(packageRoot, "worker.md", {
name: "worker",
tools: "read, write, edit, bash",
body: "Package worker.",
});
await writeStandardAgent(globalRoot, "scout.md", {
name: "scout",
body: "Global scout.",
});
await writeStandardAgent(globalRoot, "unmapped.md", {
name: "unmapped",
});
await writeStandardAgent(projectRoot, "worker.md", {
name: "worker",
body: "Project worker.",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir,
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: true,
roles: { scout: ["discover"], worker: ["implement"] },
},
});
const scout = catalog.profiles.find((profile) => profile.name === "scout");
assert.equal(scout?.source, "standard-global");
assert.equal(scout?.instructions, "Global scout.");
assert.deepEqual(scout?.roles, ["discover"]);
assert.deepEqual(scout?.selection.stages, ["discover"]);
const worker = catalog.profiles.find((profile) => profile.name === "worker");
assert.equal(worker?.source, "standard-project");
assert.equal(worker?.instructions, "Project worker.");
assert.deepEqual(worker?.roles, ["implement"]);
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
});
});
test("package and global standard definitions cannot self-declare workflow roles", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeProfile(packageRoot, "worker.md", {
name: "worker",
roles: "[review]",
selection:
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
body: "Attempted self-declared reviewer.",
});
await writeProfile(packageRoot, "unmapped.md", {
name: "unmapped",
roles: "[review]",
selection:
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectTrusted: false,
standardAgents: {
enabled: true,
roles: { worker: ["implement"] },
},
});
const worker = catalog.profiles.find((profile) => profile.name === "worker");
assert.equal(worker?.source, "standard-package");
assert.deepEqual(worker?.roles, ["implement"]);
assert.deepEqual(worker?.selection.stages, ["implement"]);
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
});
});
test("a trusted workflow profile keeps its declared roles when its name also has a standard overlay", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
await writeProfile(projectRoot, "scout.md", {
name: "scout",
roles: "[verify]",
selection:
" stages: [verify]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
body: "Project verifier.",
});
const catalog = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: true,
roles: { scout: ["discover"] },
},
});
const scout = catalog.profiles.find((profile) => profile.name === "scout");
assert.equal(scout?.source, "project");
assert.deepEqual(scout?.roles, ["verify"]);
assert.deepEqual(scout?.selection.stages, ["verify"]);
assert.equal(scout?.instructions, "Project verifier.");
});
});
test("standard agents are disabled explicitly and ignored for untrusted projects", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const packageRoot = path.join(directory, "package-agents");
const projectRoot = path.join(directory, ".pi", "agents");
await writeProfile(bundled, "general.md", { name: "general-discovery" });
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
await writeStandardAgent(projectRoot, "worker.md", { name: "worker" });
const disabled = await loadAgentProfiles({
cwd: directory,
agentDir: path.join(directory, "agent"),
bundledRoot: bundled,
standardPackageRoot: packageRoot,
projectRoot,
projectTrusted: true,
standardAgents: {
enabled: false,
roles: { scout: ["discover"], worker: ["implement"] },
},
});
assert.deepEqual(disabled.profiles.map((profile) => profile.name), ["general-discovery"]);
});
});
test("trusted project profiles override bundled profiles while first duplicate wins", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const project = path.join(directory, "project");
await writeProfile(bundled, "10-shared.md", {
name: "shared",
description: "bundled first",
});
await writeProfile(bundled, "20-shared.md", {
name: "shared",
description: "bundled duplicate",
});
await writeProfile(project, "10-shared.md", {
name: "shared",
description: "project override",
});
await writeFile(path.join(project, "20-broken.md"), "not frontmatter");
const untrusted = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectRoot: project,
projectTrusted: false,
});
assert.equal(untrusted.profiles[0]?.description, "bundled first");
assert.equal(untrusted.diagnostics.length, 1);
assert.doesNotMatch(untrusted.diagnostics[0]?.sourcePath ?? "", /project/);
const trusted = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectRoot: project,
projectTrusted: true,
});
assert.equal(trusted.profiles[0]?.description, "project override");
assert.equal(trusted.profiles[0]?.source, "project");
assert.ok(
trusted.diagnostics.some((item) =>
/duplicate profile/.test(item.message),
),
);
assert.ok(
trusted.diagnostics.some((item) =>
/missing YAML frontmatter/.test(item.message),
),
);
});
});
test("malformed profiles are isolated and unknown fields produce warnings", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
await writeProfile(bundled, "good.md", {
name: "good-profile",
description: "Coordinates https://example.test: path",
roles: "\n - discover",
extra:
"unknownField: ignored\nmetadata:\n owner: team\nlabels:\n - internal\n",
selection:
" stages:\n - discover\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n unknownSelection:\n owner: team\n",
});
await writeFile(
path.join(bundled, "quoted.md"),
String.raw`---
name: quoted-profile
description: "Path C:\\" # comment
roles: [discover]
selection:
stages: [discover]
anyTags: []
allTags: []
pathGlobs: []
keywords: ["path\\", dashboard] # comment
---
Quoted values remain valid.
`,
);
await writeFile(
path.join(bundled, "single.md"),
String.raw`---
name: single-profile
description: 'Path C:\' # comment
roles: [discover]
selection:
stages: [discover]
anyTags: []
allTags: []
pathGlobs: []
keywords: ['path\', dashboard] # comment
---
Single-quoted values keep literal backslashes.
`,
);
await writeProfile(bundled, "bad-provider.md", {
name: "bad-provider",
extra: "model: provider/model\nprovider: provider\n",
});
await writeProfile(bundled, "bad-name.md", { name: "Bad Name" });
const catalog = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectTrusted: false,
});
assert.deepEqual(
catalog.profiles.map((profile) => profile.name),
["good-profile", "quoted-profile", "single-profile"],
);
assert.equal(
catalog.profiles[0]?.description,
"Coordinates https://example.test: path",
);
assert.deepEqual(catalog.profiles[1]?.selection.keywords, [
"path\\",
"dashboard",
]);
assert.deepEqual(catalog.profiles[2]?.selection.keywords, [
"path\\",
"dashboard",
]);
assert.ok(
catalog.diagnostics.some(
(item) =>
item.severity === "warning" && /unknown profile/.test(item.message),
),
);
assert.ok(
catalog.diagnostics.some((item) =>
/unknown selection/.test(item.message),
),
);
assert.ok(
catalog.diagnostics.some((item) =>
/provider requires/.test(item.message),
),
);
assert.ok(
catalog.diagnostics.some((item) => /lowercase kebab/.test(item.message)),
);
});
});
test("a symlinked profile root is rejected", async () => {
await withTempDir(async (directory) => {
const actual = path.join(directory, "actual");
const linked = path.join(directory, "linked");
await writeProfile(actual, "agent.md", { name: "external-root" });
await symlink(actual, linked);
await assert.rejects(
loadAgentProfiles({
cwd: directory,
bundledRoot: linked,
projectTrusted: false,
}),
/profile root must not be a symlink/,
);
});
});
test("a symlinked project profile parent is rejected", async () => {
await withTempDir(async (directory) => {
const repository = path.join(directory, "repository");
const bundled = path.join(directory, "bundled");
const externalPi = path.join(directory, "external-pi");
await mkdir(repository, { recursive: true });
await writeProfile(bundled, "agent.md", { name: "bundled-agent" });
await writeProfile(path.join(externalPi, "agents"), "agent.md", {
name: "external-project",
});
await symlink(externalPi, path.join(repository, ".pi"));
await assert.rejects(
loadAgentProfiles({
cwd: repository,
bundledRoot: bundled,
projectTrusted: true,
}),
/project profile ancestry must not be a symlink/,
);
});
});
test("profile files and symlinks must resolve inside their source root", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const outside = path.join(directory, "outside.md");
await mkdir(bundled, { recursive: true });
await writeFile(
path.join(bundled, "internal.profile"),
profileDocument({ name: "internal-link" }),
);
await writeFile(outside, profileDocument({ name: "outside-link" }));
await symlink("internal.profile", path.join(bundled, "internal.md"));
await symlink(outside, path.join(bundled, "outside.md"));
await symlink("missing.profile", path.join(bundled, "broken.md"));
const catalog = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectTrusted: false,
});
assert.deepEqual(
catalog.profiles.map((profile) => profile.name),
["internal-link"],
);
assert.ok(catalog.profiles[0]?.resolvedPath.endsWith("internal.profile"));
assert.ok(
catalog.diagnostics.some((item) =>
/escapes the profile root/.test(item.message),
),
);
assert.ok(
catalog.diagnostics.some((item) => item.sourcePath.endsWith("broken.md")),
);
});
});
test("the nearest trusted ancestor project profile root is selected", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
const project = path.join(directory, ".pi", "agents");
const nestedCwd = path.join(directory, "packages", "app");
await mkdir(nestedCwd, { recursive: true });
await writeProfile(bundled, "agent.md", {
name: "shared-agent",
description: "bundled",
});
await writeProfile(project, "agent.md", {
name: "shared-agent",
description: "nearest project",
});
const trusted = await loadAgentProfiles({
cwd: nestedCwd,
bundledRoot: bundled,
projectTrusted: true,
});
assert.equal(trusted.profiles[0]?.description, "nearest project");
const untrusted = await loadAgentProfiles({
cwd: nestedCwd,
bundledRoot: bundled,
projectTrusted: false,
});
assert.equal(untrusted.profiles[0]?.description, "bundled");
});
});
test("active profile contents are pinned by digest", async () => {
await withTempDir(async (directory) => {
const bundled = path.join(directory, "bundled");
await writeProfile(bundled, "agent.md", {
name: "pinned-agent",
body: "Original instructions.",
});
const first = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectTrusted: false,
});
await writeProfile(bundled, "agent.md", {
name: "pinned-agent",
body: "Changed instructions.",
});
const second = await loadAgentProfiles({
cwd: directory,
bundledRoot: bundled,
projectTrusted: false,
});
assert.equal(first.profiles[0]?.instructions, "Original instructions.");
assert.notEqual(first.profiles[0]?.digest, second.profiles[0]?.digest);
});
});
function agent(
name: string,
selection: AgentProfile["selection"],
source: AgentProfile["source"] = "bundled",
): AgentProfile {
return {
name,
description: name,
roles: ["discover"],
selection,
instructions: "Discover.",
source,
sourcePath: name,
resolvedPath: name,
digest: name.padEnd(64, "0"),
};
}
const baseFacts: Omit<WorkflowFacts, "stage"> = {
taskKind: "feature",
requestTerms: ["dashboard"],
candidatePaths: ["src/routes/dashboard/page.tsx"],
languages: ["typescript"],
tags: ["frontend", "ui"],
riskTags: [],
};
test("eligibility records exclusion reasons and selects the most specific discovery profile", () => {
const fallback = agent("general", {
stages: ["discover"],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
});
const dashboard = agent("dashboard", {
stages: ["discover"],
anyTags: ["ui"],
allTags: ["frontend"],
pathGlobs: ["src/**/dashboard/*.tsx"],
keywords: ["dashboard"],
});
const backend = agent("backend", {
stages: ["discover"],
anyTags: ["backend"],
allTags: [],
pathGlobs: [],
keywords: [],
});
const result = selectInitialDiscoveryProfile(
[fallback, dashboard, backend],
baseFacts,
);
assert.equal(result.selected.name, "dashboard");
const excluded = result.evaluations.find(
(item) => item.profile.name === "backend",
);
assert.equal(excluded?.eligible, false);
assert.deepEqual(excluded?.reasons, ["no optional tag matched"]);
const preDiscovery = eligibleProfiles(
[fallback, dashboard],
{
...baseFacts,
stage: "discover",
requestTerms: [],
candidatePaths: [],
tags: [],
},
"discover",
);
assert.equal(
preDiscovery.find((item) => item.eligible)?.profile.name,
"general",
);
});
test("an eligible standard agent replaces the bundled fallback for its role", () => {
const emptySelection: AgentProfile["selection"] = {
stages: ["discover"],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const bundled = agent("general-discovery", emptySelection);
const scout = agent("scout", emptySelection, "standard-package");
const result = selectInitialDiscoveryProfile(
[bundled, scout],
baseFacts,
);
assert.equal(result.selected.name, "scout");
assert.deepEqual(
result.evaluations.find((item) => item.profile.name === "general-discovery")?.reasons,
["eligible standard agent available for role discover"],
);
});
test("eligibility enforces every criterion and uses a code-point name tie-breaker", () => {
const emptySelection: AgentProfile["selection"] = {
stages: ["discover"],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const wrongRole = {
...agent("wrong-role", emptySelection),
roles: ["design"] as AgentProfile["roles"],
};
const wrongStage = agent("wrong-stage", {
...emptySelection,
stages: ["design"],
});
const missingAllTag = agent("missing-all-tag", {
...emptySelection,
allTags: ["backend"],
});
const missingAnyTag = agent("missing-any-tag", {
...emptySelection,
anyTags: ["backend"],
});
const missingPath = agent("missing-path", {
...emptySelection,
pathGlobs: ["server/**/*.ts"],
});
const missingKeyword = agent("missing-keyword", {
...emptySelection,
keywords: ["api"],
});
const evaluations = eligibleProfiles(
[
wrongRole,
wrongStage,
missingAllTag,
missingAnyTag,
missingPath,
missingKeyword,
],
{ ...baseFacts, stage: "discover" },
"discover",
);
const reasons = new Map(
evaluations.map((evaluation) => [
evaluation.profile.name,
evaluation.reasons,
]),
);
assert.deepEqual(reasons.get("wrong-role"), [
"does not support role discover",
]);
assert.deepEqual(reasons.get("wrong-stage"), [
"does not support stage discover",
]);
assert.deepEqual(reasons.get("missing-all-tag"), [
"missing required tags: backend",
]);
assert.deepEqual(reasons.get("missing-any-tag"), ["no optional tag matched"]);
assert.deepEqual(reasons.get("missing-path"), ["no candidate path matched"]);
assert.deepEqual(reasons.get("missing-keyword"), [
"no request keyword matched",
]);
const tie = eligibleProfiles(
[agent("zeta", emptySelection), agent("alpha", emptySelection)],
{ ...baseFacts, stage: "discover" },
"discover",
);
assert.deepEqual(
tie
.filter((evaluation) => evaluation.eligible)
.map((evaluation) => evaluation.profile.name),
["alpha", "zeta"],
);
});
+814
View File
@@ -0,0 +1,814 @@
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
PROFILE_ROLES,
REASONING_EFFORTS,
type AgentProfile,
type AssignmentStageKind,
type EligibilityResult,
type ProfileCatalog,
type ProfileDiagnostic,
type ProfileRole,
type ProfileSelection,
type StandardAgentConfig,
type WorkflowFacts,
} from "./domain.ts";
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const TOOL_NAME = /^[a-zA-Z0-9_-]+$/;
const TOP_LEVEL_FIELDS = new Set([
"name",
"description",
"roles",
"model",
"provider",
"effort",
"tools",
"selection",
]);
const SELECTION_FIELDS = new Set([
"stages",
"anyTags",
"allTags",
"pathGlobs",
"keywords",
]);
interface ParsedFrontmatter {
values: Record<string, unknown>;
body: string;
}
interface LoadSourceResult {
profiles: AgentProfile[];
diagnostics: ProfileDiagnostic[];
}
type ProfileSourceMode = "workflow" | "standard" | "project";
export interface LoadAgentProfilesOptions {
cwd: string;
projectTrusted: boolean;
agentDir?: string;
bundledRoot?: string;
standardPackageRoot?: string;
projectRoot?: string;
standardAgents?: StandardAgentConfig;
}
function reason(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function compareText(left: string, right: string) {
if (left === right) return 0;
return left < right ? -1 : 1;
}
function isEscaped(value: string, index: number) {
let backslashes = 0;
for (
let cursor = index - 1;
cursor >= 0 && value[cursor] === "\\";
cursor--
) {
backslashes++;
}
return backslashes % 2 === 1;
}
function stripComment(value: string) {
let quote: "'" | '"' | undefined;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (quote) {
if (quote === "'" && character === "'" && value[index + 1] === "'") {
index++;
} else if (
character === quote &&
(quote === "'" || !isEscaped(value, index))
) {
quote = undefined;
}
continue;
}
if (character === "'" || character === '"') {
quote = character;
} else if (
character === "#" &&
(index === 0 || /\s/.test(value[index - 1] ?? ""))
) {
return value.slice(0, index).trimEnd();
}
}
return value;
}
function splitFlowItems(value: string, label: string) {
const items: string[] = [];
let quote: "'" | '"' | undefined;
let start = 0;
for (let index = 0; index < value.length; index++) {
const character = value[index];
if (quote) {
if (quote === "'" && character === "'" && value[index + 1] === "'") {
index++;
} else if (
character === quote &&
(quote === "'" || !isEscaped(value, index))
) {
quote = undefined;
}
} else if (character === "'" || character === '"') {
quote = character;
} else if (character === ",") {
items.push(value.slice(start, index));
start = index + 1;
}
}
if (quote) throw new Error(`${label}: unterminated quoted value`);
items.push(value.slice(start));
return items;
}
function scalar(value: string, label: string) {
const trimmed = stripComment(value).trim();
if (trimmed.length === 0) throw new Error(`${label}: value cannot be empty`);
if (trimmed.startsWith('"')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed !== "string") throw new Error("not a string");
return parsed;
} catch (error) {
throw new Error(`${label}: invalid quoted string`, { cause: error });
}
}
if (trimmed.startsWith("'")) {
if (!trimmed.endsWith("'") || trimmed.length < 2) {
throw new Error(`${label}: invalid quoted string`);
}
return trimmed.slice(1, -1).replaceAll("''", "'");
}
return trimmed;
}
function yamlValue(value: string, label: string): string | string[] {
const trimmed = stripComment(value).trim();
if (!trimmed.startsWith("[")) return scalar(trimmed, label);
if (!trimmed.endsWith("]")) throw new Error(`${label}: invalid flow array`);
const inside = trimmed.slice(1, -1).trim();
if (inside.length === 0) return [];
return splitFlowItems(inside, label).map((entry, index) =>
scalar(entry, `${label}[${index}]`),
);
}
function parseFrontmatter(
contents: string,
sourcePath: string,
): ParsedFrontmatter {
const normalized = contents.replaceAll("\r\n", "\n");
const lines = normalized.split("\n");
if (lines[0] !== "---") throw new Error("missing YAML frontmatter");
const closing = lines.indexOf("---", 1);
if (closing < 0) throw new Error("unterminated YAML frontmatter");
const values: Record<string, unknown> = {};
let section: Record<string, unknown> | undefined;
let sectionName: string | undefined;
let skippedUnknownIndent: number | undefined;
let pendingList:
| { container: Record<string, unknown>; key: string; indent: number }
| undefined;
for (let lineIndex = 1; lineIndex < closing; lineIndex++) {
const raw = lines[lineIndex] ?? "";
if (raw.trim().length === 0 || raw.trimStart().startsWith("#")) continue;
if (raw.includes("\t"))
throw new Error(
`frontmatter line ${lineIndex + 1}: tabs are not supported`,
);
const indent = raw.length - raw.trimStart().length;
if (skippedUnknownIndent !== undefined) {
if (indent > skippedUnknownIndent) continue;
skippedUnknownIndent = undefined;
}
if (indent % 2 !== 0) {
throw new Error(
`frontmatter line ${lineIndex + 1}: indentation must use pairs of spaces`,
);
}
const text = stripComment(raw.trim());
if (text.length === 0) continue;
if (text.startsWith("- ")) {
if (!pendingList || indent !== pendingList.indent + 2) {
throw new Error(
`frontmatter line ${lineIndex + 1}: unexpected list item`,
);
}
const list = pendingList.container[pendingList.key];
if (!Array.isArray(list))
throw new Error(`${sourcePath}: invalid list state`);
list.push(scalar(text.slice(2), `frontmatter line ${lineIndex + 1}`));
continue;
}
pendingList = undefined;
const separator = text.indexOf(":");
if (separator < 1)
throw new Error(`frontmatter line ${lineIndex + 1}: expected key: value`);
const key = text.slice(0, separator).trim();
const rawValue = text.slice(separator + 1).trim();
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) {
throw new Error(
`frontmatter line ${lineIndex + 1}: invalid key ${JSON.stringify(key)}`,
);
}
let container: Record<string, unknown>;
if (indent === 0) {
container = values;
section = undefined;
sectionName = undefined;
} else if (indent === 2 && section && sectionName) {
container = section;
} else {
throw new Error(`frontmatter line ${lineIndex + 1}: unsupported nesting`);
}
if (Object.hasOwn(container, key)) {
throw new Error(
`frontmatter line ${lineIndex + 1}: duplicate field ${key}`,
);
}
if (rawValue.length > 0) {
container[key] = yamlValue(rawValue, `frontmatter field ${key}`);
continue;
}
const knownField =
indent === 0 ? TOP_LEVEL_FIELDS.has(key) : SELECTION_FIELDS.has(key);
if (!knownField) {
container[key] = "<ignored nested value>";
skippedUnknownIndent = indent;
continue;
}
if (indent === 0 && key === "selection") {
section = {};
sectionName = key;
values[key] = section;
} else {
container[key] = [];
pendingList = { container, key, indent };
}
}
return {
values,
body: lines
.slice(closing + 1)
.join("\n")
.trim(),
};
}
function requiredString(value: unknown, field: string) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${field} must be a non-empty string`);
}
return value.trim();
}
function optionalString(value: unknown, field: string) {
return value === undefined ? undefined : requiredString(value, field);
}
function stringList(value: unknown, field: string, allowCsv = false) {
const list =
allowCsv && typeof value === "string"
? value.split(",").map((entry) => entry.trim())
: value;
if (!Array.isArray(list) || list.some((entry) => typeof entry !== "string")) {
throw new Error(
`${field} must be an array of strings${allowCsv ? " or CSV string" : ""}`,
);
}
const normalized = list.map((entry) => entry.trim());
if (normalized.some((entry) => entry.length === 0)) {
throw new Error(`${field} cannot contain empty values`);
}
if (new Set(normalized).size !== normalized.length) {
throw new Error(`${field} cannot contain duplicate values`);
}
return normalized;
}
function enumList<T extends string>(
value: unknown,
field: string,
allowed: readonly T[],
): T[] {
const entries = stringList(value, field);
const invalid = entries.filter((entry) => !allowed.includes(entry as T));
if (invalid.length > 0) {
throw new Error(
`${field} contains unsupported values: ${invalid.join(", ")}`,
);
}
return entries as T[];
}
function parseSelection(value: unknown) {
if (!isRecord(value)) throw new Error("selection must be an object");
const warnings = Object.keys(value)
.filter((key) => !SELECTION_FIELDS.has(key))
.map((key) => `unknown selection field ${key}`);
const stages = enumList(value.stages, "selection.stages", PROFILE_ROLES);
if (stages.length === 0)
throw new Error("selection.stages must not be empty");
const normalize = (entries: string[]) =>
entries.map((entry) => entry.toLowerCase());
const selection: ProfileSelection = {
stages,
anyTags: normalize(stringList(value.anyTags ?? [], "selection.anyTags")),
allTags: normalize(stringList(value.allTags ?? [], "selection.allTags")),
pathGlobs: stringList(value.pathGlobs ?? [], "selection.pathGlobs").map(
(entry) => entry.replaceAll("\\", "/"),
),
keywords: normalize(stringList(value.keywords ?? [], "selection.keywords")),
};
return { selection, warnings };
}
function buildStandardProfile(
parsed: ParsedFrontmatter,
contents: string,
sourcePath: string,
resolvedPath: string,
source: AgentProfile["source"],
fallbackName: string,
roles: ProfileRole[],
) {
const name = optionalString(parsed.values.name, "name") ?? fallbackName;
if (!PROFILE_NAME.test(name)) {
throw new Error("name must be lowercase kebab case");
}
const description =
optionalString(parsed.values.description, "description") ?? name;
const model = optionalString(parsed.values.model, "model");
const effort = optionalString(parsed.values.thinking, "thinking");
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
throw new Error(`thinking must be one of ${REASONING_EFFORTS.join(", ")}`);
}
const tools =
parsed.values.tools === undefined
? undefined
: stringList(parsed.values.tools, "tools", true);
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
throw new Error("tools contains an invalid tool name");
}
if (parsed.body.length === 0) {
throw new Error("agent instructions must not be empty");
}
const selection: ProfileSelection = {
stages: [...roles],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const digestInput = `${contents}\nworkflow-role-overlay:${JSON.stringify(roles)}`;
const profile: AgentProfile = {
name,
description,
roles: [...roles],
...(model ? { model } : {}),
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
...(tools ? { tools } : {}),
selection,
instructions: parsed.body,
source,
sourcePath,
resolvedPath,
digest: createHash("sha256").update(digestInput).digest("hex"),
};
return { profile, warnings: [] as string[] };
}
function buildProfile(
contents: string,
sourcePath: string,
resolvedPath: string,
source: AgentProfile["source"],
) {
const parsed = parseFrontmatter(contents, sourcePath);
const warnings = Object.keys(parsed.values)
.filter((key) => !TOP_LEVEL_FIELDS.has(key))
.map((key) => `unknown profile field ${key}`);
const name = requiredString(parsed.values.name, "name");
if (!PROFILE_NAME.test(name)) {
throw new Error("name must be lowercase kebab case");
}
const description = requiredString(parsed.values.description, "description");
const roles = enumList(
parsed.values.roles,
"roles",
PROFILE_ROLES,
) as ProfileRole[];
if (roles.length === 0) throw new Error("roles must not be empty");
const model = optionalString(parsed.values.model, "model");
const provider = optionalString(parsed.values.provider, "provider");
if (provider && (!model || model.includes("/"))) {
throw new Error("provider requires a bare model field");
}
const effort = optionalString(parsed.values.effort, "effort");
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
throw new Error(`effort must be one of ${REASONING_EFFORTS.join(", ")}`);
}
const tools =
parsed.values.tools === undefined
? undefined
: stringList(parsed.values.tools, "tools", true);
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
throw new Error("tools contains an invalid tool name");
}
if (parsed.body.length === 0)
throw new Error("agent instructions must not be empty");
const { selection, warnings: selectionWarnings } = parseSelection(
parsed.values.selection,
);
const profile: AgentProfile = {
name,
description,
roles,
...(model ? { model } : {}),
...(provider ? { provider } : {}),
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
...(tools ? { tools } : {}),
selection,
instructions: parsed.body,
source,
sourcePath,
resolvedPath,
digest: createHash("sha256").update(contents).digest("hex"),
};
return { profile, warnings: [...warnings, ...selectionWarnings] };
}
function isInside(parent: string, child: string) {
const relative = path.relative(parent, child);
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
async function loadSource(
root: string,
source: AgentProfile["source"],
required: boolean,
mode: ProfileSourceMode = "workflow",
standardRoles?: Record<string, ProfileRole[]>,
): Promise<LoadSourceResult> {
const diagnostics: ProfileDiagnostic[] = [];
let resolvedRoot: string;
try {
const rootInfo = await lstat(root);
if (rootInfo.isSymbolicLink()) {
throw new Error("profile root must not be a symlink");
}
if (!rootInfo.isDirectory()) {
throw new Error("profile root must be a directory");
}
resolvedRoot = await realpath(root);
} catch (error) {
if (!required && (error as NodeJS.ErrnoException).code === "ENOENT") {
return { profiles: [], diagnostics };
}
throw new Error(
`${root}: unable to resolve profile root: ${reason(error)}`,
{
cause: error,
},
);
}
let entries;
try {
entries = await readdir(root, { withFileTypes: true });
} catch (error) {
throw new Error(`${root}: unable to read profile root`, { cause: error });
}
const profiles: AgentProfile[] = [];
const names = new Set<string>();
for (const entry of entries.sort((left, right) =>
compareText(left.name, right.name),
)) {
if (
!entry.name.endsWith(".md") ||
(!entry.isFile() && !entry.isSymbolicLink())
)
continue;
const sourcePath = path.join(root, entry.name);
try {
const resolvedPath = await realpath(sourcePath);
if (!isInside(resolvedRoot, resolvedPath)) {
throw new Error("resolved path escapes the profile root");
}
if (!(await stat(resolvedPath)).isFile()) {
throw new Error("profile is not a regular file");
}
const contents = await readFile(resolvedPath, "utf8");
const parsed = parseFrontmatter(contents, sourcePath);
const declaredName =
optionalString(parsed.values.name, "name") ??
entry.name.replace(/\.md$/, "");
const isStandardDefinition =
mode === "standard" ||
(mode === "project" && parsed.values.roles === undefined);
let built;
if (isStandardDefinition) {
const roles = standardRoles?.[declaredName];
if (!roles) continue;
built = buildStandardProfile(
parsed,
contents,
sourcePath,
resolvedPath,
source === "project" ? "standard-project" : source,
entry.name.replace(/\.md$/, ""),
roles,
);
} else {
built = buildProfile(contents, sourcePath, resolvedPath, source);
}
const { profile, warnings } = built;
for (const message of warnings) {
diagnostics.push({ sourcePath, severity: "warning", message });
}
if (names.has(profile.name)) {
diagnostics.push({
sourcePath,
severity: "error",
message: `duplicate profile name ${profile.name}; first lexical file wins`,
});
continue;
}
names.add(profile.name);
profiles.push(profile);
} catch (error) {
diagnostics.push({
sourcePath,
severity: "error",
message: reason(error),
});
}
}
return { profiles, diagnostics };
}
async function assertProjectProfileAncestry(root: string) {
const parent = path.dirname(root);
const parentInfo = await lstat(parent);
if (parentInfo.isSymbolicLink()) {
throw new Error(
`${parent}: project profile ancestry must not be a symlink`,
);
}
if (!parentInfo.isDirectory()) {
throw new Error(`${parent}: project profile parent must be a directory`);
}
}
async function findNearestProjectRoot(cwd: string) {
let current = path.resolve(cwd);
while (true) {
const candidate = path.join(current, ".pi", "agents");
try {
await lstat(candidate);
return candidate;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error(`${candidate}: unable to inspect profile root`, {
cause: error,
});
}
}
const parent = path.dirname(current);
if (parent === current) return undefined;
current = parent;
}
}
export async function loadAgentProfiles(
options: LoadAgentProfilesOptions,
): Promise<ProfileCatalog> {
const bundledRoot =
options.bundledRoot ?? path.join(import.meta.dirname, "agents");
const bundled = await loadSource(bundledRoot, "bundled", true);
const standardAgents = options.standardAgents ?? {
enabled: false,
roles: {},
} satisfies StandardAgentConfig;
const agentDir = options.agentDir ?? getAgentDir();
const standardPackageRoot =
options.standardPackageRoot ??
path.join(
agentDir,
"extensions",
"pi-interactive-subagents",
"agents",
);
const standardPackage = standardAgents.enabled
? await loadSource(
standardPackageRoot,
"standard-package",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const standardGlobal = standardAgents.enabled
? await loadSource(
path.join(agentDir, "agents"),
"standard-global",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const projectRoot = options.projectTrusted
? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd)))
: undefined;
if (projectRoot) await assertProjectProfileAncestry(projectRoot);
const project = projectRoot
? await loadSource(
projectRoot,
"project",
false,
"project",
standardAgents.enabled ? standardAgents.roles : undefined,
)
: { profiles: [], diagnostics: [] };
const byName = new Map(
bundled.profiles.map((profile) => [profile.name, profile]),
);
for (const profile of standardPackage.profiles) {
byName.set(profile.name, profile);
}
for (const profile of standardGlobal.profiles) {
byName.set(profile.name, profile);
}
for (const profile of project.profiles) {
byName.set(profile.name, profile);
}
return {
profiles: [...byName.values()].sort((left, right) =>
compareText(left.name, right.name),
),
diagnostics: [
...bundled.diagnostics,
...standardPackage.diagnostics,
...standardGlobal.diagnostics,
...project.diagnostics,
],
};
}
function globPattern(pattern: string) {
let expression = "^";
for (let index = 0; index < pattern.length; index++) {
const character = pattern[index] ?? "";
if (character === "*" && pattern[index + 1] === "*") {
if (pattern[index + 2] === "/") {
expression += "(?:.*/)?";
index += 2;
} else {
expression += ".*";
index++;
}
} else if (character === "*") {
expression += "[^/]*";
} else if (character === "?") {
expression += "[^/]";
} else {
expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
}
}
return new RegExp(`${expression}$`);
}
export function evaluateProfileEligibility(
profile: AgentProfile,
facts: WorkflowFacts,
role: ProfileRole,
): EligibilityResult {
const reasons: string[] = [];
const tags = new Set(facts.tags.map((tag) => tag.toLowerCase()));
const terms = new Set(facts.requestTerms.map((term) => term.toLowerCase()));
const paths = facts.candidatePaths.map((candidate) =>
candidate.replaceAll("\\", "/"),
);
if (!profile.roles.includes(role))
reasons.push(`does not support role ${role}`);
if (!profile.selection.stages.includes(facts.stage)) {
reasons.push(`does not support stage ${facts.stage}`);
}
const missingTags = profile.selection.allTags.filter((tag) => !tags.has(tag));
if (missingTags.length > 0)
reasons.push(`missing required tags: ${missingTags.join(", ")}`);
if (
profile.selection.anyTags.length > 0 &&
!profile.selection.anyTags.some((tag) => tags.has(tag))
) {
reasons.push("no optional tag matched");
}
if (
profile.selection.pathGlobs.length > 0 &&
!profile.selection.pathGlobs.some((pattern) => {
const matcher = globPattern(pattern);
return paths.some((candidate) => matcher.test(candidate));
})
) {
reasons.push("no candidate path matched");
}
if (
profile.selection.keywords.length > 0 &&
!profile.selection.keywords.some((keyword) => terms.has(keyword))
) {
reasons.push("no request keyword matched");
}
return {
profile,
eligible: reasons.length === 0,
reasons,
specificity:
profile.selection.allTags.length +
profile.selection.anyTags.length +
profile.selection.pathGlobs.length +
profile.selection.keywords.length,
};
}
export function eligibleProfiles(
profiles: AgentProfile[],
facts: WorkflowFacts,
role: ProfileRole,
) {
const evaluations = profiles.map((profile) =>
evaluateProfileEligibility(profile, facts, role),
);
const hasEligibleStandard = evaluations.some(
(evaluation) =>
evaluation.eligible && evaluation.profile.source.startsWith("standard-"),
);
const ranked = hasEligibleStandard
? evaluations.map((evaluation) =>
evaluation.eligible && evaluation.profile.source === "bundled"
? {
...evaluation,
eligible: false,
reasons: [
...evaluation.reasons,
`eligible standard agent available for role ${role}`,
],
}
: evaluation,
)
: evaluations;
return ranked.sort(
(left, right) =>
Number(right.eligible) - Number(left.eligible) ||
right.specificity - left.specificity ||
compareText(left.profile.name, right.profile.name),
);
}
export function selectInitialDiscoveryProfile(
profiles: AgentProfile[],
facts: Omit<WorkflowFacts, "stage">,
) {
const evaluations = eligibleProfiles(
profiles,
{ ...facts, stage: "discover" as AssignmentStageKind },
"discover",
);
const selected = evaluations.find((evaluation) => evaluation.eligible);
if (!selected)
throw new Error("No eligible discovery Agent Profile is available");
return { selected: selected.profile, evaluations };
}
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { visibleWidth } from "@earendil-works/pi-tui";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import {
projectWorkflowProgress,
refreshWorkflowProgress,
registerWorkflowProgress,
renderWorkflowProgress,
} from "./progress.ts";
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
return {
runId: "run-1",
revision: 3,
mode: "feature",
status: "waiting",
background: false,
request: "Build dashboard",
featureName: "dashboard",
parentSessionIds: ["session-1"],
lease: {
processId: 1,
activeParentSessionId: "session-1",
heartbeatAt: 10,
},
workspace: { state: "ready", sourceRoot: "/repo" },
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "design",
taskKind: "feature",
requestTerms: [],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [
{ id: "d", kind: "discover", status: "completed" },
{ id: "p", kind: "design", status: "waiting" },
{ id: "pt", kind: "prototype", status: "pending" },
{ id: "i", kind: "implement", status: "pending" },
{ id: "v", kind: "verify", status: "pending" },
{ id: "r", kind: "review", status: "pending" },
],
gates: [
{
id: "gate",
kind: "plan-approval",
status: "open",
revision: 3,
openedAt: 12,
},
],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [],
commandReceipts: [],
uiImpactEvidence: [],
affectedUiSurfaces: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
...overrides,
};
}
test("progress projects the newest attached active run", () => {
const older = run({ runId: "older", featureName: "older", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 5 } });
const newest = run({ runId: "newest", featureName: "newest", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 20 } });
const terminal = run({ runId: "terminal", status: "completed", lease: undefined });
const historical = run({ runId: "historical", parentSessionIds: ["session-2"] });
const progress = projectWorkflowProgress([older, terminal, newest, historical], "session-1");
assert.deepEqual(progress, {
runId: "newest",
label: "newest",
stage: "DESIGN",
completed: 1,
total: 6,
gate: "Plan Approval",
});
});
test("progress clears when no attached run remains active", () => {
assert.equal(
projectWorkflowProgress([run({ status: "failed", lease: undefined })], "session-1"),
undefined,
);
assert.equal(projectWorkflowProgress([run()], "session-2"), undefined);
});
test("progress lifecycle refreshes from persistence and clears on terminal state and shutdown", async () => {
let runs = [run()];
let widget: unknown;
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
const pi = {
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
handlers.set(event, handler);
},
} as unknown as ExtensionAPI;
const runtime = {
store: { listRuns: async () => runs },
} as never;
const ctx = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
setWidget(key: string, value: unknown) {
assert.equal(key, "workflow-active-run");
widget = value;
},
},
} as unknown as ExtensionContext;
registerWorkflowProgress(pi, runtime);
await handlers.get("session_start")?.({}, ctx);
assert.equal(typeof widget, "function");
runs = [run({ status: "completed", lease: undefined })];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(widget, undefined);
runs = [run()];
await refreshWorkflowProgress(runtime, ctx);
assert.equal(typeof widget, "function");
await handlers.get("session_shutdown")?.({}, ctx);
assert.equal(widget, undefined);
});
test("progress rendering is one cell-width-safe line", () => {
const progress = projectWorkflowProgress([run()], "session-1");
assert.ok(progress);
const wide = renderWorkflowProgress(progress, 80);
assert.match(wide, /workflow dashboard DESIGN 1\/6 Gate: Plan Approval/);
const narrow = renderWorkflowProgress(progress, 28);
assert.ok(visibleWidth(narrow) <= 28);
assert.equal(narrow.includes("\n"), false);
});
+124
View File
@@ -0,0 +1,124 @@
import type {
ExtensionAPI,
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { truncateToWidth } from "@earendil-works/pi-tui";
import type { ContextTransitionRuntime } from "./context-transition.ts";
import type { WorkflowRun } from "./domain.ts";
import { deriveRunLabels } from "./run-label.ts";
const WIDGET_KEY = "workflow-active-run";
const CORE_STAGES = new Set([
"discover",
"design",
"prototype",
"implement",
"verify",
"review",
]);
export interface WorkflowProgress {
runId: string;
label: string;
stage: string;
completed: number;
total: number;
gate?: string;
}
function activityTime(run: WorkflowRun) {
return Math.max(
run.lease?.heartbeatAt ?? 0,
...run.gates.map((gate) => gate.openedAt),
...run.stages.flatMap((stage) => [stage.startedAt ?? 0, stage.finishedAt ?? 0]),
);
}
function readableGate(kind: string) {
return kind
.split("-")
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ");
}
export function newestAttachedActiveRun(
runs: WorkflowRun[],
sessionId: string | undefined,
) {
if (!sessionId) return undefined;
return runs
.filter(
(run) =>
(run.status === "running" || run.status === "waiting") &&
run.parentSessionIds.includes(sessionId),
)
.sort((left, right) => activityTime(right) - activityTime(left))[0];
}
export function projectWorkflowProgress(
runs: WorkflowRun[],
sessionId: string | undefined,
): WorkflowProgress | undefined {
const run = newestAttachedActiveRun(runs, sessionId);
if (!run) return undefined;
const active = [...run.stages]
.reverse()
.find((stage) => stage.status === "running" || stage.status === "waiting");
const gate = [...run.gates].reverse().find((entry) => entry.status === "open");
const label = deriveRunLabels(runs)[runs.indexOf(run)] ?? run.runId.slice(0, 8);
const stages = run.stages.filter((stage) => CORE_STAGES.has(stage.kind));
return {
runId: run.runId,
label,
stage: String(active?.kind ?? run.status).toUpperCase(),
completed: stages.filter((stage) => stage.status === "completed").length,
total: stages.length,
...(gate ? { gate: readableGate(gate.kind) } : {}),
};
}
export function renderWorkflowProgress(
progress: WorkflowProgress,
width: number,
theme?: Pick<Theme, "fg">,
) {
const paint = (token: "accent" | "dim" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const base = `${paint("accent", "workflow")} ${paint("text", progress.label)} ${paint("muted", progress.stage)} ${progress.completed}/${progress.total}`;
const full = progress.gate ? `${base} ${paint("dim", `Gate: ${progress.gate}`)}` : base;
return truncateToWidth(full, Math.max(1, width), "");
}
export async function refreshWorkflowProgress(
runtime: ContextTransitionRuntime,
ctx: ExtensionContext,
) {
if (ctx.mode !== "tui") return;
const progress = projectWorkflowProgress(
await runtime.store.listRuns(),
ctx.sessionManager.getSessionId(),
);
if (!progress) {
ctx.ui.setWidget(WIDGET_KEY, undefined);
return;
}
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
render(width: number) {
return [renderWorkflowProgress(progress, width, theme)];
},
invalidate() {},
}));
}
export function registerWorkflowProgress(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
pi.on("session_start", async (_event, ctx) => {
await refreshWorkflowProgress(runtime, ctx);
});
pi.on("session_shutdown", async (_event, ctx) => {
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
});
}
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
test("run labels prefer feature names and normalize request fallbacks", () => {
assert.equal(
deriveRunLabel({
runId: "12345678-aaaa",
featureName: "search-page",
request: "Ignored request",
}),
"search-page",
);
assert.equal(
deriveRunLabel({
runId: "12345678-bbbb",
request: " Audit\n\tcache behavior ",
}),
"Audit cache behavior",
);
assert.equal(
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(48) }),
"x".repeat(48),
);
});
test("request labels truncate at whole words and hard-cut overlong words", () => {
assert.equal(
deriveRunLabel({
runId: "12345678-aaaa",
request: "Build a searchable dashboard that clearly identifies every active workflow run",
}),
"Build a searchable dashboard that clearly…",
);
assert.equal(
deriveRunLabel({
runId: "12345678-bbbb",
request: `${"one ".repeat(11)}two continued`,
}),
`${"one ".repeat(11)}two…`,
);
assert.equal(
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(60) }),
`${"x".repeat(47)}…`,
);
});
test("run labels fall back to the canonical ID when requests are missing", () => {
assert.equal(deriveRunLabel({ runId: "12345678-aaaa" }), "12345678-aaaa");
assert.equal(
deriveRunLabel({ runId: "12345678-bbbb", request: "" }),
"12345678-bbbb",
);
});
test("run labels disambiguate collisions with extendable ID prefixes", () => {
assert.deepEqual(
deriveRunLabels([
{ runId: "12345678-aaaa", featureName: "search-page" },
{ runId: "12345678-bbbb", featureName: "search-page" },
{ runId: "87654321-cccc", featureName: "other" },
]),
["search-page · 12345678-a", "search-page · 12345678-b", "other"],
);
assert.deepEqual(
deriveRunLabels([
{ runId: "aaaaaaaa-1111", request: "Audit cache" },
{ runId: "bbbbbbbb-2222", request: "Audit cache" },
]),
["Audit cache · aaaaaaaa", "Audit cache · bbbbbbbb"],
);
});
@@ -0,0 +1,65 @@
const MAX_REQUEST_LABEL_CHARACTERS = 48;
const MIN_RUN_ID_PREFIX_CHARACTERS = 8;
export interface LabelledRun {
runId: string;
featureName?: string;
request?: string;
}
function requestRunLabel(request: string) {
const normalized = request.trim().replace(/\s+/g, " ");
const characters = [...normalized];
if (characters.length <= MAX_REQUEST_LABEL_CHARACTERS) return normalized;
const keptCharacters = characters.slice(0, MAX_REQUEST_LABEL_CHARACTERS - 1);
const candidate = keptCharacters.join("");
const nextCharacter = characters[MAX_REQUEST_LABEL_CHARACTERS - 1];
const lastWhitespace = candidate.search(/\s+\S*$/);
const bounded =
nextCharacter !== undefined && /\s/.test(nextCharacter)
? candidate.trimEnd()
: lastWhitespace > 0
? candidate.slice(0, lastWhitespace).trimEnd()
: candidate;
return `${bounded}…`;
}
export function deriveRunLabel(run: LabelledRun) {
const featureName = run.featureName?.trim();
if (featureName) return featureName;
const request = run.request?.trim();
return request ? requestRunLabel(request) : run.runId;
}
function uniqueRunIdPrefix(runId: string, peers: string[]) {
const characters = [...runId];
for (
let length = Math.min(MIN_RUN_ID_PREFIX_CHARACTERS, characters.length);
length < characters.length;
length++
) {
const prefix = characters.slice(0, length).join("");
if (peers.every((peer) => !peer.startsWith(prefix))) return prefix;
}
return runId;
}
/** Derives presentation labels and disambiguates only collisions in this list. */
export function deriveRunLabels<T extends LabelledRun>(runs: readonly T[]) {
const baseLabels = runs.map(deriveRunLabel);
const collisions = new Map<string, number[]>();
for (const [index, label] of baseLabels.entries()) {
const indexes = collisions.get(label) ?? [];
indexes.push(index);
collisions.set(label, indexes);
}
return baseLabels.map((label, index) => {
const indexes = collisions.get(label)!;
if (indexes.length === 1) return label;
const run = runs[index]!;
const peers = indexes
.filter((peerIndex) => peerIndex !== index)
.map((peerIndex) => runs[peerIndex]!.runId);
return `${label} · ${uniqueRunIdPrefix(run.runId, peers)}`;
});
}
@@ -0,0 +1,280 @@
"use strict";
// This file is launched by sandbox.ts in Node permission mode. It deliberately
// has no filesystem/network/child-process permissions and receives workflow
// source only over a validated IPC channel.
const vm = require("node:vm");
const sendIpc =
typeof process.send === "function" ? process.send.bind(process) : undefined;
// If a future V8 escape exposes `process`, remove the convenient bridges to
// builtins, native bindings, parent signalling, and addons before any workflow
// source is compiled. The parent still enforces the authenticated IPC protocol.
for (const capability of [
"getBuiltinModule",
"binding",
"_linkedBinding",
"dlopen",
"kill",
"abort",
"send",
]) {
try {
Object.defineProperty(process, capability, {
value: undefined,
writable: false,
configurable: false,
});
} catch {
// The VM boundary and permission mode remain mandatory controls.
}
}
const BOOTSTRAP = String.raw`
(function bootstrapScriptApi() {
"use strict";
const callHost = globalThis.__hostBridge;
delete globalThis.__hostBridge;
let nextRequestId = 0;
const unconsumed = new Set();
const inFlight = new Set();
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const key of Object.keys(value)) deepFreeze(value[key]);
return value;
}
function requestAssignment(roleValue, promptValue) {
const id = ++nextRequestId;
unconsumed.add(id);
let started;
const begin = () => {
unconsumed.delete(id);
if (!started) {
let payload;
try {
payload = JSON.stringify({
id,
role: typeof roleValue === "string" ? roleValue : String(roleValue ?? ""),
prompt: typeof promptValue === "string" ? promptValue : String(promptValue ?? ""),
});
} catch (error) {
started = Promise.reject(new Error("assign() arguments must be serializable: " + error.message));
return started;
}
inFlight.add(id);
started = callHost("assign", payload)
.then((json) => {
const result = JSON.parse(json);
if (result && result.ok === false) {
throw new Error(result.error || "Script Assignment failed");
}
return result;
})
.finally(() => inFlight.delete(id));
}
return started;
};
return Object.freeze({
then(resolve, reject) {
return begin().then(resolve, reject);
},
catch(reject) {
return begin().catch(reject);
},
finally(callback) {
return begin().finally(callback);
},
get [Symbol.toStringTag]() {
return "Promise";
},
});
}
async function mapLimited(items, concurrency, invoke) {
const results = new Array(items.length);
let next = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (true) {
const index = next++;
if (index >= items.length) return;
results[index] = await invoke(items[index]);
}
});
await Promise.all(workers);
return results;
}
async function parallel(items, options = {}) {
if (!Array.isArray(items)) throw new Error("parallel() expects an array of zero-argument assignment thunks");
const requested = options && typeof options.concurrency === "number"
? Math.floor(options.concurrency)
: 4;
if (!Number.isFinite(requested) || requested < 1) {
throw new Error("parallel(): concurrency must be a positive integer");
}
const concurrency = Math.min(4, requested);
return mapLimited(items, concurrency, (item) => {
if (typeof item !== "function") {
throw new Error("parallel() items must be zero-argument functions");
}
return item();
});
}
function stage(title) {
callHost("stage", JSON.stringify({ title: String(title) }));
}
const argsEnvelope = JSON.parse(globalThis.__argsJson);
const args = argsEnvelope.defined ? deepFreeze(argsEnvelope.value) : undefined;
delete globalThis.__argsJson;
const stringify = JSON.stringify;
function serializeResult(value) {
const seen = new WeakSet();
return stringify(value === undefined ? null : value, (_key, item) => {
if (typeof item === "bigint") return item.toString() + "n";
if (item && typeof item === "object") {
if (seen.has(item)) return "[circular]";
seen.add(item);
}
return item;
});
}
Object.defineProperties(globalThis, {
assign: { value: requestAssignment, writable: false, configurable: false },
parallel: { value: parallel, writable: false, configurable: false },
stage: { value: stage, writable: false, configurable: false },
args: { value: args, writable: false, configurable: false },
__workflowCheck: {
value: Object.freeze(() => ({
unconsumed: unconsumed.size,
inFlight: inFlight.size,
})),
writable: false,
configurable: false,
},
__workflowSerialize: {
value: Object.freeze(serializeResult),
writable: false,
configurable: false,
},
});
})();
`;
let initialized = false;
let token;
const pendingAssignments = new Map();
function send(message) {
sendIpc?.({ token, ...message });
}
function fail(error) {
const message = error instanceof Error ? error.message : String(error);
send({ kind: "error", error: message.slice(0, 16 * 1024) });
}
process.on("message", (message) => {
if (!message || typeof message !== "object") return;
if (!initialized) {
if (
message.kind !== "init" ||
typeof message.token !== "string" ||
typeof message.source !== "string" ||
typeof message.argsJson !== "string"
) {
process.exitCode = 1;
return;
}
initialized = true;
token = message.token;
run(message.source, message.argsJson);
return;
}
if (message.token !== token || message.kind !== "assignmentResult") return;
const pending = pendingAssignments.get(message.id);
if (!pending) return;
pendingAssignments.delete(message.id);
if (typeof message.resultJson === "string")
pending.resolve(message.resultJson);
else
pending.reject(
new Error(
typeof message.error === "string" ? message.error : "Agent IPC failed",
),
);
});
function run(source, argsJson) {
try {
const sandbox = Object.create(null);
sandbox.__argsJson = argsJson;
sandbox.__hostBridge = (kind, payloadJson) => {
if (kind === "stage") {
send({ kind: "stage", payloadJson });
return undefined;
}
if (kind !== "assign")
return Promise.reject(new Error("Unknown workflow operation"));
let id;
try {
id = JSON.parse(payloadJson).id;
} catch {
return Promise.reject(new Error("Invalid assign request"));
}
return new Promise((resolve, reject) => {
pendingAssignments.set(id, { resolve, reject });
send({ kind: "assign", payloadJson });
});
};
const context = vm.createContext(sandbox, {
name: "workflow",
codeGeneration: { strings: false, wasm: false },
});
new vm.Script(BOOTSTRAP, {
filename: "script-bootstrap.js",
}).runInContext(context, { timeout: 1000 });
const workflow = vm.compileFunction(
`"use strict";\nreturn (async function workflow() {\n${source}\n})();`,
["assign", "parallel", "stage", "args"],
{ filename: "workflow-script.js", parsingContext: context },
);
context.__workflowBody = workflow;
const invoke = `
(() => {
const workflowBody = globalThis.__workflowBody;
delete globalThis.__workflowBody;
globalThis.__workflowPromise = Promise.resolve(
workflowBody(assign, parallel, stage, args),
).then(async (value) => {
await Promise.resolve();
const pending = __workflowCheck();
if (pending.unconsumed > 0) {
throw new Error("Workflow created " + pending.unconsumed + " unawaited assign() call(s)");
}
if (pending.inFlight > 0) {
throw new Error("Workflow returned before " + pending.inFlight + " assign call(s) settled");
}
return __workflowSerialize(value);
});
})();
`;
new vm.Script(invoke, { filename: "script-invoke.js" }).runInContext(
context,
{ timeout: 1000 },
);
Promise.resolve(context.__workflowPromise)
.then((resultJson) => {
if (typeof resultJson !== "string")
throw new Error("Workflow result was not serializable");
send({ kind: "result", resultJson });
})
.catch(fail);
} catch (error) {
fail(error);
}
}
@@ -0,0 +1,145 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
parseScriptAssignmentPayload,
runScriptSandbox,
ScriptRequestBudget,
validateScriptIpcEnvelope,
} from "./script-runtime.ts";
function run(
source: string,
overrides: Partial<Parameters<typeof runScriptSandbox>[0]> = {},
) {
const abort = new AbortController();
return runScriptSandbox({
source,
args: undefined,
cwd: process.cwd(),
signal: abort.signal,
onAssign: async (role, prompt) => ({
ok: true,
output: `${role}:${prompt}`,
}),
onStage: () => {},
...overrides,
});
}
test("script sandbox exposes only stage, assign, parallel, and immutable args", async () => {
const stages: string[] = [];
const result = await run(
`
stage("Audit");
const replies = await parallel([
() => assign("review", "one"),
() => assign("review", "two"),
], { concurrency: 99 });
return {
replies: replies.map((reply) => reply.output),
processType: typeof process,
requireType: typeof require,
fetchType: typeof fetch,
nestedArgsFrozen: Object.isFrozen(args.nested),
};
`,
{
args: { nested: { value: "immutable" } },
onStage: (title) => stages.push(title),
},
);
assert.deepEqual(result, {
replies: ["review:one", "review:two"],
processType: "undefined",
requireType: "undefined",
fetchType: "undefined",
nestedArgsFrozen: true,
});
assert.deepEqual(stages, ["Audit"]);
});
test("script sandbox rejects unawaited assignments and synchronous loops", async () => {
let calls = 0;
await assert.rejects(
run(`assign("review", "orphan"); return "done";`, {
onAssign: async () => {
calls++;
return { ok: true, output: "unexpected" };
},
}),
/unawaited assign/,
);
assert.equal(calls, 0);
await assert.rejects(run(`while (true) {}`), /timed out/);
});
test("script cancellation aborts active assignments and terminates execution", async () => {
const controller = new AbortController();
let startedResolve: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
startedResolve = resolve;
});
let requestAborted = false;
const pending = run(`return await assign("review", "pending");`, {
signal: controller.signal,
onAssign: async (_role, _prompt, signal) => {
startedResolve?.();
await new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
requestAborted = true;
resolve();
},
{ once: true },
);
});
return { ok: false, output: "", error: "Assignment was aborted" };
},
});
await started;
controller.abort(new Error("cancel fixture"));
await assert.rejects(pending, /Script was aborted/);
assert.equal(requestAborted, true);
});
test("script IPC rejects unauthenticated, malformed, oversized, duplicate, and over-budget requests", () => {
assert.throws(
() => validateScriptIpcEnvelope({ token: "wrong", kind: "assign" }, "right"),
/invalid IPC message/,
);
assert.throws(() => parseScriptAssignmentPayload("{"), /malformed assignment JSON/);
assert.throws(
() => parseScriptAssignmentPayload("x".repeat(512 * 1024 + 1)),
/oversized assignment request/,
);
assert.throws(
() => parseScriptAssignmentPayload(JSON.stringify({ id: 1, role: "", prompt: "x" })),
/invalid assignment request/,
);
const duplicate = new ScriptRequestBudget();
duplicate.accept(1);
assert.throws(() => duplicate.accept(1), /duplicate assignment request/);
const budget = new ScriptRequestBudget();
for (let id = 1; id <= 32; id++) budget.accept(id);
assert.throws(() => budget.accept(33), /request budget/);
});
test("script stage updates and total runtime are bounded", async () => {
await assert.rejects(
run(`for (let index = 0; index < 65; index++) stage("flood-" + index);`),
/stage update budget/,
);
await assert.rejects(
run(`while (true) { await Promise.resolve(); }`, { runtimeTimeoutMs: 20 }),
/wall-clock time limit/,
);
});
test("script source and args are bounded", async () => {
await assert.rejects(run("x".repeat(512 * 1024 + 1)), /source exceeds/);
await assert.rejects(
run("return null", { args: "x".repeat(256 * 1024 + 1) }),
/args exceed/,
);
});
@@ -0,0 +1,339 @@
import { randomBytes } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { safeStringify, toSerializable } from "../_workflows/serialization.ts";
const MAX_SOURCE_BYTES = 512 * 1024;
const MAX_ARGS_BYTES = 256 * 1024;
const MAX_RESULT_BYTES = 1024 * 1024;
const MAX_ASSIGNMENT_MESSAGE_BYTES = 512 * 1024;
const MAX_ASSIGNMENT_REQUESTS = 32;
const MAX_STAGE_UPDATES = 64;
const DEFAULT_SCRIPT_RUNTIME_MS = 10 * 60 * 1000;
export interface ScriptAssignmentResult {
ok: boolean;
output: string;
structured?: unknown;
error?: string;
}
export interface RunScriptSandboxOptions {
source: string;
args: unknown;
cwd: string;
signal: AbortSignal;
runtimeTimeoutMs?: number;
onAssign: (
role: string,
prompt: string,
signal: AbortSignal,
requestId: number,
) => Promise<ScriptAssignmentResult>;
onStage: (title: string) => void;
}
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function errorText(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function terminateChild(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const force = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
}, 1_000);
force.unref?.();
}
export function validateScriptIpcEnvelope(raw: unknown, token: string) {
if (!isRecord(raw) || raw.token !== token || typeof raw.kind !== "string") {
throw new Error("Script sandbox sent an invalid IPC message");
}
return raw;
}
export function parseScriptAssignmentPayload(payloadJson: unknown) {
if (
typeof payloadJson !== "string" ||
byteLength(payloadJson) > MAX_ASSIGNMENT_MESSAGE_BYTES
) {
throw new Error("Script sandbox sent an oversized assignment request");
}
let payload: unknown;
try {
payload = JSON.parse(payloadJson);
} catch {
throw new Error("Script sandbox sent malformed assignment JSON");
}
if (
!isRecord(payload) ||
!Number.isSafeInteger(payload.id) ||
typeof payload.id !== "number" ||
payload.id < 1 ||
typeof payload.role !== "string" ||
payload.role.trim().length === 0 ||
payload.role.length > 80 ||
typeof payload.prompt !== "string" ||
payload.prompt.length > 100_000
) {
throw new Error("Script sandbox sent an invalid assignment request");
}
return {
id: payload.id,
role: payload.role.trim(),
prompt: payload.prompt,
};
}
export class ScriptRequestBudget {
private readonly ids = new Set<number>();
private count = 0;
accept(id: number) {
if (this.ids.has(id)) {
throw new Error("Script sandbox sent a duplicate assignment request");
}
if (++this.count > MAX_ASSIGNMENT_REQUESTS) {
throw new Error("Script sandbox exceeded its assignment request budget");
}
this.ids.add(id);
}
}
/**
* Execute orchestration code in a separate, permission-restricted Node process.
* The child can only invoke the narrow agent/phase IPC protocol and is always
* terminated on completion, cancellation, or protocol failure. The workflow
* itself and its assignment requests have no wall-clock deadline. Active requests
* are aborted only when the workflow is cancelled or the sandbox is cleaned up.
*/
export function runScriptSandbox(options: RunScriptSandboxOptions) {
if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
return Promise.reject(
new Error("This Node runtime cannot enforce script child permissions"),
);
}
if (byteLength(options.source) > MAX_SOURCE_BYTES) {
return Promise.reject(
new Error(`Script source exceeds the ${MAX_SOURCE_BYTES} byte limit`),
);
}
try {
const exactArgsJson = JSON.stringify({
defined: options.args !== undefined,
value: options.args,
});
if (exactArgsJson && byteLength(exactArgsJson) > MAX_ARGS_BYTES) {
return Promise.reject(new Error("Script args exceed the IPC limit"));
}
} catch {
// The bounded serializer below handles cycles and hostile values.
}
const argsJson = safeStringify(
{ defined: options.args !== undefined, value: options.args },
{ maxBytes: MAX_ARGS_BYTES + 1, maxDepth: 16, maxNodes: 10_000 },
);
if (byteLength(argsJson) > MAX_ARGS_BYTES) {
return Promise.reject(new Error("Script args exceed the IPC limit"));
}
return new Promise<unknown>((resolve, reject) => {
const workerPath = fileURLToPath(
new URL("./sandbox-child.cjs", import.meta.url),
);
const child = spawn(
process.execPath,
[
"--permission",
`--allow-fs-read=${path.dirname(workerPath)}`,
"--max-old-space-size=128",
"--stack-size=2048",
workerPath,
],
{
cwd: options.cwd,
env: {
PATH: process.env.PATH ?? "",
NODE_NO_WARNINGS: "1",
},
stdio: ["ignore", "ignore", "ignore", "ipc"],
},
);
const token = randomBytes(24).toString("hex");
const requestBudget = new ScriptRequestBudget();
const activeAssignmentRequests = new Map<number, AbortController>();
let stageCount = 0;
let finished = false;
const runtimeTimer = setTimeout(
() => finish(new Error("Script exceeded its wall-clock time limit")),
options.runtimeTimeoutMs ?? DEFAULT_SCRIPT_RUNTIME_MS,
);
runtimeTimer.unref?.();
const cleanup = () => {
for (const abortController of activeAssignmentRequests.values()) {
abortController.abort(new Error("Script stopped"));
}
activeAssignmentRequests.clear();
clearTimeout(runtimeTimer);
options.signal.removeEventListener("abort", onAbort);
child.removeAllListeners("message");
child.removeAllListeners("error");
child.removeAllListeners("exit");
terminateChild(child);
};
const finish = (error?: Error, value?: unknown) => {
if (finished) return;
finished = true;
cleanup();
if (error) reject(error);
else resolve(value);
};
const onAbort = () => finish(new Error("Script was aborted"));
options.signal.addEventListener("abort", onAbort, { once: true });
if (options.signal.aborted) {
onAbort();
return;
}
child.on("error", (error) => finish(error));
child.on("exit", (code, exitSignal) => {
if (!finished) {
finish(
new Error(
`Script sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
),
);
}
});
child.on("message", (message: unknown) => {
let raw: Record<string, unknown>;
try {
raw = validateScriptIpcEnvelope(message, token);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
return;
}
if (raw.kind === "stage") {
if (++stageCount > MAX_STAGE_UPDATES) {
finish(new Error("Script sandbox exceeded its stage update budget"));
return;
}
if (
typeof raw.payloadJson !== "string" ||
byteLength(raw.payloadJson) > 4096
) {
finish(new Error("Script sandbox sent an invalid stage update"));
return;
}
try {
const payload: unknown = JSON.parse(raw.payloadJson);
if (!isRecord(payload) || typeof payload.title !== "string") {
throw new Error("invalid title");
}
options.onStage(payload.title.slice(0, 160));
} catch {
finish(new Error("Script sandbox sent an invalid stage update"));
}
return;
}
if (raw.kind === "assign") {
let payload: ReturnType<typeof parseScriptAssignmentPayload>;
try {
payload = parseScriptAssignmentPayload(raw.payloadJson);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
return;
}
try {
requestBudget.accept(payload.id);
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)));
return;
}
const id = payload.id;
const abortController = new AbortController();
const sendResult = (result: ScriptAssignmentResult) => {
if (!activeAssignmentRequests.delete(id)) return;
if (finished || !child.connected) return;
const normalized = toSerializable(result, {
maxDepth: 16,
maxNodes: 10_000,
maxStringBytes: 128 * 1024,
});
let resultJson = JSON.stringify(normalized);
if (byteLength(resultJson) > MAX_ASSIGNMENT_MESSAGE_BYTES) {
resultJson = JSON.stringify({
ok: false,
output: "",
error: "Assignment result exceeded the script IPC output limit",
});
}
child.send({ token, kind: "assignmentResult", id, resultJson });
};
activeAssignmentRequests.set(id, abortController);
void options
.onAssign(
payload.role.trim(),
payload.prompt,
abortController.signal,
payload.id,
)
.then(sendResult)
.catch((error) =>
sendResult({ ok: false, output: "", error: errorText(error) }),
);
return;
}
if (raw.kind === "result") {
if (
typeof raw.resultJson !== "string" ||
byteLength(raw.resultJson) > MAX_RESULT_BYTES
) {
finish(new Error("Script result exceeded the IPC limit"));
return;
}
try {
const normalized = toSerializable(JSON.parse(raw.resultJson));
finish(undefined, JSON.parse(JSON.stringify(normalized)));
} catch (error) {
finish(
new Error(`Script returned invalid JSON: ${errorText(error)}`),
);
}
return;
}
if (raw.kind === "error" && typeof raw.error === "string") {
finish(new Error(raw.error.slice(0, 16 * 1024)));
return;
}
finish(new Error("Script sandbox sent an unknown IPC message"));
});
child.send(
{
kind: "init",
token,
source: options.source,
argsJson,
},
(error) => {
if (error) finish(error);
},
);
});
}
@@ -0,0 +1,517 @@
import assert from "node:assert/strict";
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import { readVerifiedReportArtifact, WorkflowStore } from "./store.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "workflow-store-"));
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
function workflow(runId = "run-1"): WorkflowRun {
return {
runId,
revision: 0,
mode: "feature",
status: "running",
background: false,
request: "Build the feature",
featureName: "build-feature",
parentSessionIds: ["session-1"],
lease: {
processId: 100,
activeParentSessionId: "session-1",
heartbeatAt: 1,
},
workspace: {
state: "creating",
sourceRoot: "/repository",
root: `/repository/.worktrees/${runId}`,
branch: "feature/build-feature",
baseHead: "abc123",
},
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
pinnedProfiles: [],
preflightEligibility: [],
facts: {
stage: "discover",
taskKind: "feature",
requestTerms: ["build", "feature"],
candidatePaths: [],
languages: [],
tags: [],
riskTags: [],
},
stages: [],
gates: [],
decisions: [],
assignments: [],
prototypeCandidates: [],
prototypeCleanupCompleted: false,
deliveries: [],
commandReceipts: [],
mutatorProfileNames: [],
uiImpactEvidence: [],
affectedUiSurfaces: [],
reviewCycles: 0,
verificationCycles: 0,
};
}
test("plan revisions use immutable artifact paths while updating plan.md convenience", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
await store.createRun(workflow());
const first = await store.savePlan("run-1", 1, "# First plan");
const second = await store.savePlan("run-1", 2, "# Second plan");
assert.notEqual(first.path, second.path);
assert.equal(await readFile(first.path, "utf8"), "# First plan\n");
assert.equal(await readFile(second.path, "utf8"), "# Second plan\n");
});
});
test("prototype evidence is retained after throwaway source cleanup", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
await store.createRun(workflow());
const candidate = path.join(directory, "worktree", "prototypes", "candidate-1");
await mkdir(candidate, { recursive: true });
const reportPath = path.join(candidate, "mobile.json");
const screenshotPath = path.join(candidate, "mobile.png");
await writeFile(reportPath, '{"result":"passed"}', "utf8");
await writeFile(screenshotPath, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
const artifact = await store.savePrototypeArtifact(
"run-1",
"candidate-1",
{ path: "prototypes/candidate-1" },
path.join(directory, "worktree"),
["prototypes/candidate-1/mobile.json", "prototypes/candidate-1/mobile.png"],
);
await rm(candidate, { recursive: true });
assert.equal(artifact.retainedFiles.length, 2);
assert.equal(await readFile(artifact.retainedFiles[0]!, "utf8"), '{"result":"passed"}');
await stat(artifact.path);
});
});
test("terminal reports bound large persisted Decisions instead of blocking completion", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow();
run.status = "completed";
run.decisions.push({
gateId: "gate-large",
revision: 1,
idempotencyKey: "large-decision",
decidedAt: 1,
value: {
kind: "answer-questions",
answers: { large: "x".repeat(900_000) },
},
});
run.verificationEvidence = {
focusedBehavioralTests: true,
verifierSucceeded: true,
commands: Array.from({ length: 1_024 }, (_, index) => ({
id: `command-${index}`,
executable: "node",
args: ["--test", "x".repeat(2_000)],
ownedPaths: [],
kind: "test" as const,
status: "succeeded" as const,
})),
};
await store.createRun(run);
const report = await store.saveReport(run);
const contents = await readFile(report.path, "utf8");
assert.match(contents, /^# workflow build-feature/m);
assert.match(contents, /- Run ID: run-1/);
assert.match(contents, /\[truncated\]/);
assert.match(contents, /report truncated to 1 MiB/);
assert.ok(Buffer.byteLength(contents, "utf8") <= 1024 * 1024);
});
});
test("supported version-zero snapshots migrate missing lifecycle collections", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const legacy = workflow();
const record = legacy as unknown as Record<string, unknown>;
delete record.background;
delete record.assignments;
delete record.deliveries;
delete record.prototypeCandidates;
delete record.prototypeCleanupCompleted;
delete record.commandReceipts;
delete record.uiImpactEvidence;
delete record.affectedUiSurfaces;
delete record.reviewCycles;
delete record.verificationCycles;
delete (legacy.effectiveConfig as Partial<typeof legacy.effectiveConfig>)
.standardAgents;
const runDirectory = path.join(
directory,
"state",
"runs",
legacy.runId,
"revisions",
);
await mkdir(runDirectory, { recursive: true });
await writeFile(
path.join(runDirectory, "0.json"),
`${JSON.stringify({ schemaVersion: 0, run: legacy })}\n`,
"utf8",
);
const loaded = await store.loadRun(legacy.runId);
assert.equal(loaded.background, false);
assert.deepEqual(loaded.assignments, []);
assert.deepEqual(loaded.deliveries, []);
assert.deepEqual(loaded.prototypeCandidates, []);
assert.deepEqual(loaded.commandReceipts, []);
assert.equal(loaded.prototypeCleanupCompleted, false);
assert.deepEqual(
loaded.effectiveConfig.standardAgents,
DEFAULT_WORKFLOW_CONFIG.standardAgents,
);
});
});
test("report reads reject tampering before dashboard display", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
const run = workflow();
run.status = "completed";
await store.createRun(run);
const report = await store.saveReport(run);
await writeFile(report.path, "tampered\n", "utf8");
await assert.rejects(
readVerifiedReportArtifact(report),
/digest mismatch/,
);
});
});
test("delivery claims admit one process and recover only after expiry", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(path.join(directory, "state"));
await store.createRun(workflow());
const claims = await Promise.all([
store.claimDelivery("run-1", "gate:key", "owner-a", 1_000),
store.claimDelivery("run-1", "gate:key", "owner-b", 1_000),
]);
assert.equal(claims.filter(Boolean).length, 1);
assert.equal(
await store.claimDelivery("run-1", "gate:key", "owner-c", 30_999),
false,
);
assert.equal(
await store.claimDelivery("run-1", "gate:key", "owner-c", 31_000),
true,
);
});
});
test("current snapshots default newly introduced lifecycle collections", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
const legacy = workflow();
const legacyRecord = legacy as unknown as Record<string, unknown>;
delete legacyRecord.mutatorProfileNames;
delete legacyRecord.prototypeCleanupCompleted;
delete legacyRecord.uiImpactEvidence;
delete legacyRecord.affectedUiSurfaces;
delete legacyRecord.reviewCycles;
delete legacyRecord.verificationCycles;
await store.createRun(legacy);
const loaded = await store.loadRun(legacy.runId);
assert.deepEqual(loaded.mutatorProfileNames, []);
assert.equal(loaded.prototypeCleanupCompleted, false);
assert.deepEqual(loaded.uiImpactEvidence, []);
assert.deepEqual(loaded.affectedUiSurfaces, []);
assert.equal(loaded.reviewCycles, 0);
assert.equal(loaded.verificationCycles, 0);
});
});
test("atomic snapshots leave the complete previous value when replacement is interrupted", async () => {
await withTempDir(async (directory) => {
let interrupt = false;
const store = new WorkflowStore(directory, {
beforeRename(target) {
if (interrupt && target.endsWith(path.join("revisions", "1.json"))) {
throw new Error("simulated interruption");
}
},
});
const initial = workflow();
await store.createRun(initial);
interrupt = true;
const changed = structuredClone(initial);
changed.revision = 1;
changed.status = "waiting";
await assert.rejects(store.saveRun(changed), /simulated interruption/);
assert.deepEqual(await store.loadRun(initial.runId), initial);
interrupt = false;
await store.saveRun(changed);
assert.deepEqual(await store.loadRun(initial.runId), changed);
const info = await stat(
path.join(directory, "runs", initial.runId, "workflow.json"),
);
assert.equal(info.mode & 0o777, 0o600);
});
});
test("revision-checked saves serialize concurrent writers", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
const initial = workflow();
await store.createRun(initial);
const left = structuredClone(initial);
left.revision = 1;
left.status = "waiting";
const right = structuredClone(initial);
right.revision = 1;
right.status = "aborted";
const results = await Promise.allSettled([
store.saveRun(left, 0),
store.saveRun(right, 0),
]);
assert.equal(
results.filter((result) => result.status === "fulfilled").length,
1,
);
const rejected = results.find((result) => result.status === "rejected");
assert.match(
String(rejected && rejected.status === "rejected" && rejected.reason),
/Stale workflow revision/,
);
assert.equal((await store.loadRun(initial.runId)).revision, 1);
});
});
test("a published revision survives interruption before the convenience snapshot", async () => {
await withTempDir(async (directory) => {
let interrupt = false;
const store = new WorkflowStore(directory, {
beforeRename(target) {
if (interrupt && target.endsWith("workflow.json")) {
throw new Error("simulated pointer interruption");
}
},
});
const initial = workflow();
await store.createRun(initial);
const changed = structuredClone(initial);
changed.revision = 1;
changed.status = "waiting";
interrupt = true;
await assert.rejects(store.saveRun(changed, 0), /pointer interruption/);
assert.deepEqual(await store.loadRun(initial.runId), changed);
});
});
test("run creation is atomically published and a pending start can retry", async () => {
await withTempDir(async (directory) => {
let interrupt = true;
const interruptedStore = new WorkflowStore(directory, {
beforeRename(target) {
if (interrupt && target.endsWith("request.md")) {
throw new Error("simulated creation crash");
}
},
});
const initial = workflow();
await interruptedStore.reserveStart(
"launch-key",
"fingerprint",
initial.runId,
);
await assert.rejects(
interruptedStore.createRun(initial),
/simulated creation crash/,
);
assert.deepEqual(await interruptedStore.listRuns(), []);
interrupt = false;
const reservation = await interruptedStore.reserveStart(
"launch-key",
"fingerprint",
initial.runId,
);
assert.equal(reservation.created, false);
await interruptedStore.createRun(initial);
assert.deepEqual(
(await interruptedStore.listRuns()).map((run) => run.runId),
[initial.runId],
);
});
});
test("a concurrent publisher is reloaded instead of quarantined", async () => {
await withTempDir(async (directory) => {
let markMiss: (() => void) | undefined;
let releaseMiss: (() => void) | undefined;
const missed = new Promise<void>((resolve) => {
markMiss = resolve;
});
const wait = new Promise<void>((resolve) => {
releaseMiss = resolve;
});
const slow = new WorkflowStore(directory, {
async afterRunLoadMiss() {
markMiss?.();
await wait;
},
});
const fast = new WorkflowStore(directory);
const slowRun = workflow("run-race");
slowRun.request = "Slow publisher";
const fastRun = workflow("run-race");
fastRun.request = "Fast publisher";
const slowPublication = slow.createRun(slowRun);
await missed;
const fastPublication = await fast.createRun(fastRun);
assert.equal(fastPublication.created, true);
releaseMiss?.();
const slowResult = await slowPublication;
assert.equal(slowResult.created, false);
assert.equal(slowResult.run.request, "Fast publisher");
assert.equal((await fast.loadRun("run-race")).request, "Fast publisher");
});
});
test("incomplete legacy run directories are skipped and fail closed", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
await store.initialize();
const initial = workflow();
await mkdir(path.join(directory, "runs", initial.runId), { mode: 0o700 });
assert.deepEqual(await store.listRuns(), []);
await assert.rejects(
store.createRun(initial),
/incomplete published directory/,
);
});
});
test("start reservations are idempotent and recover an incomplete record", async () => {
await withTempDir(async (directory) => {
let interrupt = true;
const store = new WorkflowStore(directory, {
beforeRename(target) {
if (interrupt && target.endsWith("record.json")) {
throw new Error("simulated reservation crash");
}
},
});
await assert.rejects(
store.reserveStart("launch-key", "fingerprint-a", "run-a"),
/simulated reservation crash/,
);
interrupt = false;
const recovered = await store.reserveStart(
"launch-key",
"fingerprint-a",
"run-a",
);
assert.equal(recovered.created, true);
const duplicate = await store.reserveStart(
"launch-key",
"fingerprint-a",
"run-b",
);
assert.equal(duplicate.created, false);
assert.equal(duplicate.record.runId, "run-a");
await assert.rejects(
store.reserveStart("launch-key", "fingerprint-b", "run-c"),
/another start command/,
);
const result = { runId: "run-a", revision: 3 };
await store.completeStart("launch-key", result);
assert.deepEqual(await store.getStart("launch-key"), {
key: "launch-key",
fingerprint: "fingerprint-a",
runId: "run-a",
status: "completed",
result,
});
});
});
test("start completion keeps the highest durable run revision", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
await store.reserveStart("completion-key", "fingerprint", "run-a");
await store.completeStart("completion-key", {
runId: "run-a",
revision: 5,
status: "waiting",
});
await store.completeStart("completion-key", {
runId: "run-a",
revision: 2,
status: "running",
});
const completed = await store.getStart("completion-key");
assert.equal(completed?.status, "completed");
assert.equal(
(completed?.result as { revision?: number } | undefined)?.revision,
5,
);
});
});
test("concurrent start intents publish one authoritative run ID", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
const [left, right] = await Promise.all([
store.reserveStart("claimed-key", "fingerprint", "run-left"),
store.reserveStart("claimed-key", "fingerprint", "run-right"),
]);
assert.equal([left, right].filter((result) => result.created).length, 1);
assert.equal(left.record.runId, right.record.runId);
assert.ok(
left.record.runId === "run-left" || left.record.runId === "run-right",
);
});
});
test("run artifacts and plans are bounded, pinned, and discoverable", async () => {
await withTempDir(async (directory) => {
const store = new WorkflowStore(directory);
const first = workflow("run-a");
const second = workflow("run-b");
await store.createRun(first);
await store.createRun(second);
const plan = await store.savePlan(
first.runId,
4,
"# Plan\n\nImplement it.",
);
assert.equal(plan.revision, 4);
assert.match(plan.digest, /^[a-f0-9]{64}$/);
assert.deepEqual(
(await store.listRuns()).map((run) => run.runId),
["run-a", "run-b"],
);
});
});
+930
View File
@@ -0,0 +1,930 @@
import { createHash, randomUUID } from "node:crypto";
import {
link,
lstat,
mkdir,
open,
readdir,
readFile,
realpath,
rename,
rm,
stat,
} from "node:fs/promises";
import * as path from "node:path";
import {
DEFAULT_WORKFLOW_CONFIG,
type PlanArtifact,
type WorkflowReportArtifact,
type WorkflowRun,
} from "./domain.ts";
import { deriveRunLabel } from "./run-label.ts";
const SNAPSHOT_VERSION = 1;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_DOCUMENT_BYTES = 1024 * 1024;
const MAX_PROTOTYPE_ARTIFACT_BYTES = 8 * 1024 * 1024;
async function readVerifiedDocument(
artifact: { path: string; digest: string },
label: string,
maxBytes = MAX_DOCUMENT_BYTES,
) {
const details = await stat(artifact.path);
if (!details.isFile() || details.size > maxBytes) {
throw new Error(`${label} artifact exceeds the verified document limit`);
}
const contents = await readFile(artifact.path, "utf8");
const digest = createHash("sha256").update(contents).digest("hex");
if (digest !== artifact.digest) {
throw new Error(`${label} artifact digest mismatch: ${artifact.path}`);
}
return contents;
}
export function readVerifiedPlanArtifact(artifact: PlanArtifact) {
return readVerifiedDocument(artifact, "Plan");
}
export function readVerifiedReportArtifact(artifact: WorkflowReportArtifact) {
return readVerifiedDocument(artifact, "Report");
}
export function readVerifiedTranscriptArtifact(artifact: {
path: string;
digest: string;
}) {
return readVerifiedDocument(artifact, "Transcript", MAX_JSON_BYTES);
}
interface StoredSnapshot {
schemaVersion: 0 | typeof SNAPSHOT_VERSION;
run: WorkflowRun;
}
export interface AssignmentLeaseRecord {
token: string;
processId: number;
heartbeatAt: number;
}
export interface StartRecord {
key: string;
fingerprint: string;
runId: string;
status: "pending" | "completed";
result?: unknown;
}
export interface WorkflowStoreOptions {
beforeRename?: (target: string) => void | Promise<void>;
afterRunLoadMiss?: (runId: string) => void | Promise<void>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function json(value: unknown, label: string) {
const contents = `${JSON.stringify(value, null, 2)}\n`;
if (Buffer.byteLength(contents) > MAX_JSON_BYTES) {
throw new Error(`${label} exceeds ${MAX_JSON_BYTES} bytes`);
}
return contents;
}
async function syncDirectory(directory: string) {
let handle;
try {
handle = await open(directory, "r");
await handle.sync();
} catch {
// Some filesystems do not support directory fsync.
} finally {
await handle?.close();
}
}
export class WorkflowStore {
readonly root: string;
private readonly options: WorkflowStoreOptions;
constructor(root: string, options: WorkflowStoreOptions = {}) {
this.root = root;
this.options = options;
}
async initialize() {
await mkdir(this.root, { recursive: true, mode: 0o700 });
await mkdir(this.runsRoot(), { recursive: true, mode: 0o700 });
await mkdir(this.startsRoot(), { recursive: true, mode: 0o700 });
}
async createRun(run: WorkflowRun) {
await this.initialize();
const directory = this.runDirectory(run.runId);
try {
return { run: await this.loadRun(run.runId), created: false as const };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await this.options.afterRunLoadMiss?.(run.runId);
const existing = await stat(directory).catch(() => undefined);
if (existing) {
try {
return { run: await this.loadRun(run.runId), created: false as const };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
throw new Error(
`Workflow Run ${run.runId} has an incomplete published directory; inspect and remove it before retrying`,
);
}
const staging = path.join(
this.runsRoot(),
`.${run.runId}.${process.pid}.${randomUUID()}.creating`,
);
try {
await mkdir(staging, { mode: 0o700 });
await this.writeInitialRun(staging, run);
await rename(staging, directory);
await syncDirectory(this.runsRoot());
return { run, created: true as const };
} catch (error) {
await rm(staging, { recursive: true, force: true });
if (
(error as NodeJS.ErrnoException).code === "EEXIST" ||
(error as NodeJS.ErrnoException).code === "ENOTEMPTY"
) {
return {
run: await this.loadRun(run.runId),
created: false as const,
};
}
throw error;
}
}
async saveRun(run: WorkflowRun, expectedRevision?: number) {
const current = await this.loadRun(run.runId);
const expected = expectedRevision ?? current.revision;
if (current.revision !== expected || run.revision !== expected + 1) {
throw new Error(
`Stale workflow revision ${expected}; current revision is ${current.revision}`,
);
}
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
const contents = json(snapshot, `workflow ${run.runId}`);
try {
await this.atomicCreate(
this.revisionPath(run.runId, run.revision),
contents,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
const latest = await this.loadRun(run.runId);
throw new Error(
`Stale workflow revision ${expected}; current revision is ${latest.revision}`,
);
}
throw error;
}
await this.atomicWrite(
path.join(this.runDirectory(run.runId), "workflow.json"),
contents,
);
}
async createAssignmentLease(
runId: string,
assignmentId: string,
lease: AssignmentLeaseRecord,
) {
const existing = await this.loadAssignmentLease(runId, assignmentId);
if (existing && existing.token !== lease.token) {
throw new Error(`Assignment ${assignmentId} already has an active lease`);
}
await this.atomicWrite(
this.assignmentLeasePath(runId, assignmentId),
json(lease, `assignment lease ${assignmentId}`),
);
return lease;
}
async heartbeatAssignmentLease(
runId: string,
assignmentId: string,
token: string,
heartbeatAt: number,
) {
const existing = await this.loadAssignmentLease(runId, assignmentId);
if (!existing || existing.token !== token) return false;
await this.atomicWrite(
this.assignmentLeasePath(runId, assignmentId),
json({ ...existing, heartbeatAt }, `assignment lease ${assignmentId}`),
);
return true;
}
async loadAssignmentLease(
runId: string,
assignmentId: string,
): Promise<AssignmentLeaseRecord | undefined> {
try {
const parsed = await readJson(
this.assignmentLeasePath(runId, assignmentId),
);
if (
!isRecord(parsed) ||
typeof parsed.token !== "string" ||
typeof parsed.processId !== "number" ||
typeof parsed.heartbeatAt !== "number"
) {
throw new Error(`Assignment ${assignmentId} has an invalid lease`);
}
return {
token: parsed.token,
processId: parsed.processId,
heartbeatAt: parsed.heartbeatAt,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
async releaseAssignmentLease(
runId: string,
assignmentId: string,
token: string,
) {
const existing = await this.loadAssignmentLease(runId, assignmentId);
if (!existing || existing.token !== token) return false;
await rm(this.assignmentLeasePath(runId, assignmentId), { force: true });
return true;
}
async loadRun(runId: string): Promise<WorkflowRun> {
const revisions = path.join(this.runDirectory(runId), "revisions");
const entries = await readdir(revisions);
const latest = entries
.map((entry) => /^(\d+)\.json$/.exec(entry))
.filter((match): match is RegExpExecArray => match !== null)
.map((match) => Number(match[1]))
.sort((left, right) => right - left)[0];
if (latest === undefined) {
throw new Error(`${revisions}: no workflow revisions`);
}
const file = this.revisionPath(runId, latest);
const parsed = await readJson(file);
if (
!isRecord(parsed) ||
(parsed.schemaVersion !== 0 &&
parsed.schemaVersion !== SNAPSHOT_VERSION) ||
!isRecord(parsed.run) ||
parsed.run.runId !== runId
) {
throw new Error(`${file}: unsupported or invalid workflow snapshot`);
}
// SAFETY: saveRun is the sole writer; the wrapper version and run ID were validated above.
const run = parsed.run as unknown as WorkflowRun;
run.background ??= false;
run.assignments ??= [];
run.deliveries ??= [];
run.prototypeCandidates ??= [];
run.commandReceipts ??= [];
run.mutatorProfileNames ??= [];
run.prototypeCleanupCompleted ??= false;
run.uiImpactEvidence ??= [];
run.affectedUiSurfaces ??= [];
run.reviewCycles ??= 0;
run.verificationCycles ??= 0;
run.effectiveConfig.standardAgents ??= structuredClone(
DEFAULT_WORKFLOW_CONFIG.standardAgents,
);
for (const assignment of run.assignments) {
if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {};
if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {};
}
for (const delivery of run.deliveries) {
try {
const receipt = await readJson(
this.deliveryReceiptPath(run.runId, delivery.key),
);
if (isRecord(receipt) && typeof receipt.sentAt === "number") {
delivery.state = "sent";
delivery.sentAt = receipt.sentAt;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return run;
}
async listRuns() {
await this.initialize();
const entries = await readdir(this.runsRoot(), { withFileTypes: true });
const runs: WorkflowRun[] = [];
for (const entry of entries.sort((left, right) =>
left.name === right.name ? 0 : left.name < right.name ? -1 : 1,
)) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const snapshot = path.join(this.runsRoot(), entry.name, "workflow.json");
try {
await stat(snapshot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
}
runs.push(await this.loadRun(entry.name));
}
return runs;
}
async saveReport(run: WorkflowRun) {
const lines = [
`# workflow ${deriveRunLabel(run)}`,
"",
`- Run ID: ${run.runId}`,
`- Status: ${run.status}`,
`- Revision: ${run.revision}`,
`- Mode: ${run.mode}`,
`- Request: ${reportText(run.request, 8_000)}`,
`- Worktree: ${run.workspace.root ?? "not required"}`,
`- Branch: ${run.workspace.branch ?? "not required"}`,
`- Plan: ${run.planArtifact?.path ?? "none"}`,
"",
"## Gates",
...boundedReportEntries(
run.gates,
(gate) =>
`- ${gate.kind}: ${gate.status} (${gate.id}, revision ${gate.revision})`,
),
"",
"## Decisions",
...boundedReportEntries(
run.decisions,
(decision) =>
`- ${decision.gateId} revision ${decision.revision}: ${reportText(decision.value)}`,
),
"",
"## Assignments",
...boundedReportEntries(
run.assignments,
(assignment) =>
`- ${assignment.role}/${assignment.profileName}: ${assignment.status} (${assignment.id})`,
1_024,
),
"",
"## Prototype Evidence",
...boundedReportEntries(
run.prototypeCandidates,
(candidate) =>
`- ${candidate.id}: ${reportText(candidate.route, 500)} | ${candidate.artifactPath}`,
),
"",
"## Verification",
...(run.verificationEvidence
? boundedReportEntries(
run.verificationEvidence.commands,
(command) =>
`- ${command.kind}: ${command.status} | ${reportText(`${command.executable} ${command.args.join(" ")}`, 2_000)}`,
1_024,
)
: ["- none"]),
"",
"## Review",
"```json",
reportText(run.reviewVerdict ?? null, 64 * 1024),
"```",
...(run.error
? [
"",
"## Error",
`${run.error.code}: ${reportText(run.error.message, 8_000)}`,
]
: []),
];
const contents = boundedReportDocument(lines.join("\n"));
const digest = createHash("sha256").update(contents).digest("hex");
const reportPath = path.join(
this.runDirectory(run.runId),
"artifacts",
`report-${run.revision}-${digest}.md`,
);
await this.atomicWrite(reportPath, contents);
return { path: reportPath, digest, revision: run.revision };
}
async ensureResult(run: WorkflowRun) {
try {
await stat(path.join(this.runDirectory(run.runId), "result.json"));
return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await this.publishResult(run);
}
async publishResult(run: WorkflowRun) {
await this.atomicWrite(
path.join(this.runDirectory(run.runId), "result.json"),
json(
{
runId: run.runId,
revision: run.reportArtifact?.revision ?? run.revision,
snapshotRevision: run.revision,
status: run.status,
reportPath: run.reportArtifact?.path,
planPath: run.planArtifact?.path,
worktreePath: run.workspace.root,
reviewVerdict: run.reviewVerdict,
error: run.error,
},
"workflow result",
),
);
}
async savePlan(runId: string, revision: number, contents: string) {
const bounded = boundedDocument(contents, "plan");
const digest = createHash("sha256").update(bounded).digest("hex");
const directory = this.runDirectory(runId);
const file = path.join(directory, "plan.md");
const versionedFile = path.join(
directory,
"artifacts",
`plan-${revision}-${digest}.md`,
);
await this.atomicWrite(versionedFile, bounded);
await this.atomicWrite(file, bounded);
const artifact: PlanArtifact = {
path: versionedFile,
revision,
digest,
};
return artifact;
}
async savePrototypeArtifact(
runId: string,
candidateId: string,
evidence: unknown,
sourceRoot: string,
artifactFiles: string[],
) {
if (!/^[a-zA-Z0-9_-]+$/.test(candidateId)) {
throw new Error("invalid prototype candidate ID");
}
const root = await realpath(sourceRoot);
const artifactRoot = path.join(
this.runDirectory(runId),
"artifacts",
"prototypes",
candidateId,
);
let totalBytes = 0;
const retainedFiles: string[] = [];
for (const relativePath of [...new Set(artifactFiles)]) {
if (!relativePath || path.isAbsolute(relativePath)) {
throw new Error("prototype artifact path must be relative");
}
const target = await realpath(path.resolve(root, relativePath));
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error("prototype artifact path escapes source root");
}
const details = await lstat(target);
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error("prototype artifacts must be regular files");
}
const contents = await readFile(target);
totalBytes += contents.byteLength;
if (totalBytes > MAX_PROTOTYPE_ARTIFACT_BYTES) {
throw new Error("prototype artifacts exceed the 8 MiB retained limit");
}
const digest = createHash("sha256").update(contents).digest("hex");
const destination = path.join(
artifactRoot,
`${digest}-${path.basename(relativePath)}`,
);
await this.atomicWrite(destination, contents);
retainedFiles.push(destination);
}
const file = path.join(artifactRoot, "candidate.json");
const contents = json(
{ candidate: evidence, retainedFiles },
`prototype candidate ${candidateId}`,
);
await this.atomicWrite(file, contents);
return { path: file, retainedFiles };
}
async saveAssignmentTranscript(
runId: string,
assignmentId: string,
transcript: unknown[],
) {
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
throw new Error("invalid assignment ID");
}
const contents = json(transcript, `assignment transcript ${assignmentId}`);
const file = path.join(
this.runDirectory(runId),
"artifacts",
"transcripts",
`${assignmentId}.json`,
);
await this.atomicWrite(file, contents);
return {
path: file,
digest: createHash("sha256").update(contents).digest("hex"),
};
}
async claimDelivery(
runId: string,
key: string,
token: string,
claimedAt: number,
) {
const claimPath = this.deliveryClaimPath(runId, key);
await mkdir(path.dirname(claimPath), { recursive: true, mode: 0o700 });
for (let attempt = 0; attempt < 3; attempt++) {
let handle;
try {
handle = await open(claimPath, "wx", 0o600);
await handle.writeFile(
`${JSON.stringify({ key, token, claimedAt })}\n`,
"utf8",
);
await handle.sync();
await handle.close();
await syncDirectory(path.dirname(claimPath));
return true;
} catch (error) {
await handle?.close();
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const existing = await readJson(claimPath).catch(() => undefined);
if (
!isRecord(existing) ||
typeof existing.claimedAt !== "number" ||
claimedAt - existing.claimedAt < 30_000
) {
return false;
}
const stalePath = `${claimPath}.${randomUUID()}.stale`;
try {
await rename(claimPath, stalePath);
await rm(stalePath, { force: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return false;
}
async clearDeliveryClaim(runId: string, key: string) {
await rm(this.deliveryClaimPath(runId, key), { force: true });
}
async releaseDeliveryClaim(runId: string, key: string, token: string) {
const claimPath = this.deliveryClaimPath(runId, key);
const existing = await readJson(claimPath).catch(() => undefined);
if (!isRecord(existing) || existing.token !== token) return;
const releasedPath = `${claimPath}.${randomUUID()}.released`;
try {
await rename(claimPath, releasedPath);
await rm(releasedPath, { force: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
async markDeliverySent(runId: string, key: string, sentAt: number) {
const run = await this.loadRun(runId);
const delivery = run.deliveries.find((candidate) => candidate.key === key);
if (!delivery || delivery.state === "sent") return run;
await this.atomicWrite(
this.deliveryReceiptPath(runId, key),
`${JSON.stringify({ key, sentAt })}\n`,
);
delivery.state = "sent";
delivery.sentAt = sentAt;
return run;
}
async getStart(key: string) {
const directory = this.startDirectory(key);
try {
await stat(path.join(directory, "record.json"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
return this.readStartRecord(directory);
}
async reserveStart(key: string, fingerprint: string, runId: string) {
await this.initialize();
const directory = this.startDirectory(key);
await mkdir(directory, { recursive: true, mode: 0o700 });
let created = false;
let record: StartRecord;
try {
record = await this.readStartRecord(directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
const candidate: StartRecord = {
key,
fingerprint,
runId,
status: "pending",
};
try {
await this.atomicCreate(
path.join(directory, "record.json"),
json(candidate, "start record"),
);
record = candidate;
created = true;
} catch (createError) {
if ((createError as NodeJS.ErrnoException).code !== "EEXIST") {
throw createError;
}
record = await this.readStartRecord(directory);
}
}
if (record.key !== key || record.fingerprint !== fingerprint) {
throw new Error(
"idempotency key was already used with another start command",
);
}
return { created, claimed: true as const, record };
}
async completeStart(key: string, result: unknown) {
const directory = this.startDirectory(key);
const record = await this.readStartRecord(directory);
if (
!isRecord(result) ||
result.runId !== record.runId ||
!Number.isInteger(result.revision) ||
(result.revision as number) < 0
) {
throw new Error("start result does not match its Workflow Run revision");
}
const target = path.join(
directory,
"results",
`${result.revision as number}.json`,
);
try {
await this.atomicCreate(target, json(result, "start result"));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
return this.readStartRecord(directory);
}
async findRunByBranch(branch: string) {
const runs = await this.listRuns();
return runs.find((run) => run.workspace.branch === branch);
}
private async writeInitialRun(directory: string, run: WorkflowRun) {
await mkdir(path.join(directory, "profiles"), { mode: 0o700 });
await mkdir(path.join(directory, "handoffs"), { mode: 0o700 });
await mkdir(path.join(directory, "artifacts"), { mode: 0o700 });
await mkdir(path.join(directory, "assignment-leases"), { mode: 0o700 });
await mkdir(path.join(directory, "revisions"), { mode: 0o700 });
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
const contents = json(snapshot, `workflow ${run.runId}`);
await this.atomicWrite(
path.join(directory, "revisions", "0.json"),
contents,
);
await this.atomicWrite(path.join(directory, "workflow.json"), contents);
await this.atomicWrite(
path.join(directory, "request.md"),
boundedDocument(run.request, "request"),
);
await this.atomicWrite(
path.join(directory, "config.json"),
json(run.effectiveConfig, "effective configuration"),
);
for (const profile of run.pinnedProfiles) {
await this.atomicWrite(
path.join(directory, "profiles", `${profile.name}.json`),
json(profile, `profile ${profile.name}`),
);
}
}
private runsRoot() {
return path.join(this.root, "runs");
}
private startsRoot() {
return path.join(this.root, "starts");
}
private runDirectory(runId: string) {
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) throw new Error("invalid run ID");
return path.join(this.runsRoot(), runId);
}
private deliveryClaimPath(runId: string, key: string) {
const digest = createHash("sha256").update(key).digest("hex");
return path.join(
this.runDirectory(runId),
"delivery-claims",
`${digest}.json`,
);
}
private deliveryReceiptPath(runId: string, key: string) {
const digest = createHash("sha256").update(key).digest("hex");
return path.join(
this.runDirectory(runId),
"delivery-receipts",
`${digest}.json`,
);
}
private revisionPath(runId: string, revision: number) {
if (!Number.isInteger(revision) || revision < 0) {
throw new Error("invalid workflow revision");
}
return path.join(this.runDirectory(runId), "revisions", `${revision}.json`);
}
private assignmentLeasePath(runId: string, assignmentId: string) {
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
throw new Error("invalid assignment ID");
}
return path.join(
this.runDirectory(runId),
"assignment-leases",
`${assignmentId}.json`,
);
}
private startDigest(key: string) {
return createHash("sha256").update(key).digest("hex");
}
private startDirectory(key: string) {
return path.join(this.startsRoot(), this.startDigest(key));
}
private async readStartRecord(directory: string) {
const file = path.join(directory, "record.json");
let parsed: unknown;
try {
parsed = await readJson(file);
} catch (error) {
throw error;
}
if (
!isRecord(parsed) ||
typeof parsed.key !== "string" ||
typeof parsed.fingerprint !== "string" ||
typeof parsed.runId !== "string" ||
(parsed.status !== "pending" && parsed.status !== "completed")
) {
throw new Error(`${file}: invalid start record`);
}
// SAFETY: all persisted StartRecord identity and discriminator fields were validated above.
const intent = parsed as unknown as StartRecord;
const resultsDirectory = path.join(directory, "results");
let resultEntries: string[] = [];
try {
resultEntries = await readdir(resultsDirectory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const latest = resultEntries
.map((entry) => /^(\d+)\.json$/.exec(entry))
.filter((match): match is RegExpExecArray => match !== null)
.map((match) => Number(match[1]))
.sort((left, right) => right - left)[0];
if (latest === undefined) return { ...intent, status: "pending" as const };
const result = await readJson(
path.join(resultsDirectory, `${latest}.json`),
);
if (
!isRecord(result) ||
result.runId !== intent.runId ||
result.revision !== latest
) {
throw new Error(`${resultsDirectory}: invalid start result`);
}
return { ...intent, status: "completed" as const, result };
}
private async atomicCreate(target: string, contents: string) {
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
const temporary = path.join(
path.dirname(target),
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
);
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(contents, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await this.options.beforeRename?.(target);
await link(temporary, target);
await rm(temporary, { force: true });
await syncDirectory(path.dirname(target));
} catch (error) {
await handle?.close();
await rm(temporary, { force: true });
throw error;
}
}
private async atomicWrite(target: string, contents: string | Uint8Array) {
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
const temporary = path.join(
path.dirname(target),
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
);
let handle;
try {
handle = await open(temporary, "wx", 0o600);
if (typeof contents === "string") await handle.writeFile(contents, "utf8");
else await handle.writeFile(contents);
await handle.sync();
await handle.close();
handle = undefined;
await this.options.beforeRename?.(target);
await rename(temporary, target);
await syncDirectory(path.dirname(target));
} catch (error) {
await handle?.close();
await rm(temporary, { force: true });
throw error;
}
}
}
function reportText(value: unknown, maxCharacters = 2_000) {
const text = typeof value === "string" ? value : JSON.stringify(value);
const characters = [...text];
return characters.length <= maxCharacters
? text
: `${characters.slice(0, maxCharacters).join("")}\n[truncated]`;
}
function boundedReportEntries<T>(
entries: T[],
render: (entry: T) => string,
limit = 128,
) {
const included = entries.slice(-limit).map(render);
if (entries.length > limit) {
included.unshift(`- [${entries.length - limit} earlier entries omitted]`);
}
return included;
}
function boundedReportDocument(contents: string) {
const normalized = contents.endsWith("\n") ? contents : `${contents}\n`;
if (Buffer.byteLength(normalized, "utf8") <= MAX_DOCUMENT_BYTES) return normalized;
const marker = "\n[report truncated to 1 MiB]\n";
const limit = MAX_DOCUMENT_BYTES - Buffer.byteLength(marker, "utf8");
let bytes = 0;
const kept: string[] = [];
for (const character of normalized) {
const width = Buffer.byteLength(character, "utf8");
if (bytes + width > limit) break;
kept.push(character);
bytes += width;
}
return `${kept.join("")}${marker}`;
}
function boundedDocument(contents: string, label: string) {
if (Buffer.byteLength(contents) > MAX_DOCUMENT_BYTES) {
throw new Error(`${label} exceeds ${MAX_DOCUMENT_BYTES} bytes`);
}
return contents.endsWith("\n") ? contents : `${contents}\n`;
}
async function readJson(file: string) {
const info = await stat(file);
if (info.size > MAX_JSON_BYTES)
throw new Error(`${file}: JSON file is too large`);
const contents = await readFile(file, "utf8");
try {
return JSON.parse(contents) as unknown;
} catch (error) {
throw new Error(`${file}: invalid JSON`, { cause: error });
}
}
+726
View File
@@ -0,0 +1,726 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import {
compactWorkflowDetails,
registerWorkflowTool,
workflowCommandFromTool,
} from "./tool.ts";
import { WorkspaceValidationError } from "./workspace.ts";
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return {
runId: "run-1",
featureName: "search-page",
request: "Build search page",
revision: 4,
status: "waiting",
workspace: {
state: "ready",
sourceRoot: "/repo",
root: "/repo/.worktrees/run-1",
branch: "feature/search",
baseHead: "abc123",
},
uiImpactEvidence: [],
affectedUiSurfaces: [],
stages: [],
assignments: [],
mutatorProfileNames: [],
reviewCycles: 0,
verificationCycles: 0,
...overrides,
background: overrides.background ?? false,
};
}
test("tool commands derive active tools for feature and analysis scripts", () => {
assert.deepEqual(
workflowCommandFromTool(
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "feature-key",
workflow: {
mode: "feature-script",
source: "return null",
args: '{"scope":"routes"}',
insertionPoint: "before-design",
},
},
["read", "grep", "bash"],
),
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "feature-key",
script: {
source: "return null",
args: { scope: "routes" },
insertionPoint: "before-design",
activeToolNames: ["read", "grep", "bash"],
},
},
);
const analysis = workflowCommandFromTool(
{
action: "start",
request: "Audit persistence",
workflow: {
mode: "analysis-script",
source: "return await assign('review', 'Inspect')",
},
idempotencyKey: "analysis-key",
},
["read", "ls"],
);
assert.deepEqual(
workflowCommandFromTool(
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
["read"],
),
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-feature-key",
allowDirtySource: true,
},
);
assert.equal(analysis.action, "start-analysis");
assert.deepEqual(
analysis.action === "start-analysis" ? analysis.activeToolNames : [],
["read", "ls"],
);
});
test("tool commands reject open or incomplete action shapes", () => {
assert.throws(
() =>
workflowCommandFromTool(
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "feature-key",
workflow: { mode: "feature-script", source: "return null" },
},
["read"],
),
/requires workflow.source and workflow.insertionPoint/,
);
assert.throws(
() =>
workflowCommandFromTool(
{
action: "decide",
runId: "run-1",
gateId: "gate-1",
expectedRevision: 2,
idempotencyKey: "decision-key",
decision: undefined,
},
["read"],
),
/decision is required/,
);
});
test("compact tool details expose only valid continuation intents", () => {
const details = compactWorkflowDetails(
"inspect",
workflowView({
openGate: {
id: "gate-1",
kind: "implementation-context",
status: "open",
revision: 4,
openedAt: 1,
},
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
implementationContext: {
gateId: "gate-1",
runRevision: 3,
planRevision: 2,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
}),
);
assert.deepEqual(details.continuation, {
command: "/workflow continue",
runId: "run-1",
});
assert.deepEqual(details.openGate?.payload, {
preparationId: "prep-1",
planRevision: 2,
contextRevision: 3,
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
outcome: undefined,
error: undefined,
});
assert.equal(
compactWorkflowDetails(
"inspect",
workflowView({
openGate: {
id: "gate-1",
kind: "implementation-context",
status: "open",
revision: 4,
openedAt: 1,
},
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
implementationContext: {
gateId: "gate-1",
runRevision: 3,
planRevision: 2,
preparationId: "prep-1",
activeParentSessionId: "session-1",
strategy: "fresh-session",
status: "handoff-pending",
method: "fresh-session",
},
}),
"session-2",
).continuation,
undefined,
);
assert.equal(
compactWorkflowDetails("inspect", workflowView()).continuation,
undefined,
);
});
test("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError(
"dirty-source",
"source changes are omitted",
{
sourceChanges: [{ status: "??", path: "local-notes.md" }],
sourceChangesOmitted: 2,
},
);
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
let confirmation = "";
await definition.execute(
"dirty-call",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key",
},
new AbortController().signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async (factory: (
tui: { requestRender(): void; terminal: { rows: number } },
theme: { fg(color: string, text: string): string; bg(color: string, text: string): string },
keybindings: object,
done: (value: boolean) => void,
) => { render(width: number): string[] }) => {
const component = factory(
{ requestRender() {}, terminal: { rows: 24 } },
{
fg: (_color, text) => text,
bg: (_color, text) => text,
},
{},
() => {},
);
confirmation = component.render(100).join("\n");
return true;
},
},
} as unknown as ExtensionContext,
);
assert.match(confirmation, /local-notes\.md/);
assert.match(confirmation, /2 additional paths omitted/);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true],
);
const explicitContext = {
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
confirmation = "unexpected prompt";
return false;
},
},
} as unknown as ExtensionContext;
confirmation = "";
await assert.rejects(
definition.execute(
"dirty-call-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
explicitContext,
),
/source changes are omitted/,
);
assert.equal(confirmation, "");
await definition.execute(
"dirty-call-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
explicitContext,
);
assert.equal(confirmation, "");
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false, true, false, true],
);
});
test("RPC dirty-source starts never open TUI overlays and require the flag", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && !command.allowDirtySource) {
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
}
return workflowView();
},
} as never;
},
});
assert.ok(definition);
const ctx = {
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("RPC must not open a custom overlay");
},
},
} as unknown as ExtensionContext;
await assert.rejects(
definition.execute(
"rpc-dirty-rejected",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-rejected",
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await assert.rejects(
definition.execute(
"rpc-dirty-refused",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-refused",
allowDirtySource: false,
},
new AbortController().signal,
undefined,
ctx,
),
/explicit approval required/,
);
await definition.execute(
"rpc-dirty-approved",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "dirty-key-approved",
allowDirtySource: true,
},
new AbortController().signal,
undefined,
ctx,
);
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[undefined, false, true],
);
});
test("start tool cancellation waits for engine settlement before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
let markStarted = () => {};
let releaseStart = () => {};
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
const blocked = new Promise<void>((resolve) => {
releaseStart = resolve;
});
let cancelled = false;
let cancellationReason = new Error("cancelled before mutation");
let laterMutation = false;
let engineOperation: Promise<WorkflowView> | undefined;
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async cancelStart(
_idempotencyKey: string,
_abortIdempotencyKey: string,
reason: Error,
) {
cancelled = true;
cancellationReason = reason;
await engineOperation?.catch(() => {});
},
execute() {
engineOperation = (async () => {
markStarted();
await blocked;
if (cancelled) throw cancellationReason;
laterMutation = true;
return workflowView();
})();
return engineOperation;
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-slow-start",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-slow-start-key",
},
controller.signal,
undefined,
{
mode: "rpc",
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
);
await started;
let toolSettled = false;
void operation.then(
() => {
toolSettled = true;
},
() => {
toolSettled = true;
},
);
controller.abort(new Error("cancelled by tool caller"));
await Promise.resolve();
assert.equal(toolSettled, false);
releaseStart();
await assert.rejects(operation, /cancelled by tool caller/);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(laterMutation, false);
});
test("cancellation during dirty-source confirmation cannot start a retry", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
let worktreeCreations = 0;
let showConfirmation = () => {};
const confirmationShown = new Promise<void>((resolve) => {
showConfirmation = resolve;
});
let finishConfirmation = (_value: boolean) => {};
const confirmation = new Promise<boolean>((resolve) => {
finishConfirmation = resolve;
});
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {
async getStart() {
return { runId: "not-created" };
},
async loadRun() {
throw new Error("not created");
},
} as never,
async engineForContext() {
return {
async cancelStart() {},
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "start" && command.allowDirtySource === true) {
worktreeCreations++;
return workflowView();
}
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-dirty-confirmation",
{
action: "start",
request: "Build search",
featureName: "search-page",
idempotencyKey: "cancel-dirty-key",
},
controller.signal,
undefined,
{
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
showConfirmation();
return confirmation;
},
},
} as unknown as ExtensionContext,
);
await confirmationShown;
controller.abort(new Error("cancelled while confirming"));
await assert.rejects(operation, /cancelled while confirming/);
finishConfirmation(true);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
commands.map((command) =>
command.action === "start" ? command.allowDirtySource : undefined,
),
[false],
);
assert.equal(worktreeCreations, 0);
});
test("tool cancellation aborts its active Workflow Run before rejecting", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
let finishDecision = (_view: WorkflowView) => {};
const decision = new Promise<WorkflowView>((resolve) => {
finishDecision = resolve;
});
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {
async loadRun() {
return { runId: "run-1", revision: 4, status: "running" };
},
} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
if (command.action === "abort") {
const aborted = workflowView({ status: "aborted", revision: 5 });
finishDecision(aborted);
return aborted;
}
return decision;
},
} as never;
},
});
assert.ok(definition);
const controller = new AbortController();
const operation = definition.execute(
"cancel-call",
{
action: "decide",
runId: "run-1",
gateId: "gate-1",
expectedRevision: 4,
idempotencyKey: "decision-key",
decision: { kind: "recover", resolution: "inspect" },
},
controller.signal,
undefined,
{
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
);
controller.abort(new Error("cancelled by test"));
await assert.rejects(operation, /cancelled by test/);
assert.equal(commands.some((command) => command.action === "abort"), true);
});
test("inspect errors retain the supplied run ID when no label can be derived", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute() {
throw new Error("not found");
},
} as never;
},
});
assert.ok(definition);
await assert.rejects(
definition.execute(
"inspect-call",
{ action: "inspect", runId: "missing-run-id" },
new AbortController().signal,
undefined,
{
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
),
/Workflow Run missing-run-id could not be loaded/,
);
});
test("registered tool executes through the context engine and injects host tools", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools() {
return ["read", "smart_compact"];
},
} as unknown as ExtensionAPI;
registerWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute(command: WorkflowCommand) {
commands.push(structuredClone(command));
return workflowView();
},
} as never;
},
});
assert.equal(definition?.name, "workflow");
assert.ok(definition);
const result = await definition.execute(
"call-1",
{
action: "decide",
runId: "run-1",
gateId: "gate-1",
expectedRevision: 4,
idempotencyKey: "context-report",
decision: {
kind: "report-context-preparation",
preparationId: "prep-1",
planRevision: 2,
method: "smart_compact",
outcome: "succeeded",
},
},
new AbortController().signal,
undefined,
{
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
);
assert.deepEqual(
commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined,
["read", "smart_compact"],
);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Workflow search-page/);
assert.equal((result.details as { runId: string }).runId, "run-1");
});
+556
View File
@@ -0,0 +1,556 @@
import { StringEnum } from "@earendil-works/pi-ai";
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "typebox";
import { isValidContextContinuation } from "./continuation.ts";
import type { GateDecision } from "./domain.ts";
import {
executeHostDecision,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { deriveRunLabel } from "./run-label.ts";
import { workflowConfirm } from "./tui.ts";
import { WorkspaceValidationError } from "./workspace.ts";
const ACTIONS = ["start", "inspect", "decide", "abort"] as const;
const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
const INSERTION_POINTS = [
"before-design",
"after-design",
"before-review",
] as const;
const DECISION_KINDS = [
"answer-questions",
"approve-plan",
"revise-plan",
"accept-prototype",
"refine-prototypes",
"select-implementation-context",
"report-context-preparation",
"recover",
] as const;
const WorkflowDefinition = Type.Object(
{
mode: StringEnum(WORKFLOW_MODES),
insertionPoint: Type.Optional(StringEnum(INSERTION_POINTS)),
source: Type.Optional(Type.String()),
args: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
const GateDecisionInput = Type.Object(
{
kind: StringEnum(DECISION_KINDS),
answers: Type.Optional(Type.Record(Type.String(), Type.String())),
approved: Type.Optional(Type.Boolean()),
acceptedUiImpact: Type.Optional(
StringEnum(["none", "minor", "non-trivial"] as const),
),
comment: Type.Optional(Type.String()),
feedback: Type.Optional(Type.String()),
candidateId: Type.Optional(Type.String()),
candidateRevision: Type.Optional(Type.Integer({ minimum: 0 })),
browserReviewed: Type.Optional(Type.Boolean()),
strategy: Type.Optional(
StringEnum(["fresh-session", "smart-compact", "current-session"] as const),
),
preparationId: Type.Optional(Type.String()),
planRevision: Type.Optional(Type.Integer({ minimum: 0 })),
method: Type.Optional(StringEnum(["smart_compact"] as const)),
outcome: Type.Optional(
StringEnum(["unavailable", "succeeded", "no-op", "failed"] as const),
),
error: Type.Optional(Type.String()),
resolution: Type.Optional(
StringEnum(["retry", "inspect", "abort"] as const),
),
},
{ additionalProperties: false },
);
export const WorkflowParameters = Type.Object(
{
action: StringEnum(ACTIONS),
request: Type.Optional(Type.String()),
featureName: Type.Optional(Type.String()),
workflow: Type.Optional(WorkflowDefinition),
concurrency: Type.Optional(Type.Integer({ minimum: 1 })),
background: Type.Optional(Type.Boolean()),
allowDirtySource: Type.Optional(Type.Boolean()),
runId: Type.Optional(Type.String()),
gateId: Type.Optional(Type.String()),
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
decision: Type.Optional(GateDecisionInput),
idempotencyKey: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export type WorkflowInput = Static<typeof WorkflowParameters>;
function requiredString(value: unknown, label: string) {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${label} is required`);
}
return value.trim();
}
function scriptArgs(value: string | undefined) {
if (value === undefined) return undefined;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
export function workflowCommandFromTool(
input: WorkflowInput,
activeToolNames: string[],
): WorkflowCommand {
if (input.action === "start") {
const request = requiredString(input.request, "request");
const idempotencyKey = requiredString(
input.idempotencyKey,
"idempotencyKey",
);
const definition = input.workflow ?? { mode: "feature" as const };
if (definition.mode === "analysis-script") {
return {
action: "start-analysis",
request,
source: requiredString(definition.source, "workflow.source"),
...(definition.args !== undefined
? { args: scriptArgs(definition.args) }
: {}),
activeToolNames: [...activeToolNames],
idempotencyKey,
};
}
const featureName = requiredString(input.featureName, "featureName");
if (
definition.mode === "feature-script" &&
(!definition.insertionPoint || !definition.source)
) {
throw new Error(
"feature-script requires workflow.source and workflow.insertionPoint",
);
}
return {
action: "start",
request,
featureName,
idempotencyKey,
...(input.concurrency !== undefined
? { concurrency: input.concurrency }
: {}),
...(input.background !== undefined
? { background: input.background }
: {}),
...(input.allowDirtySource !== undefined
? { allowDirtySource: input.allowDirtySource }
: {}),
...(definition.mode === "feature-script"
? {
script: {
source: requiredString(definition.source, "workflow.source"),
...(definition.args !== undefined
? { args: scriptArgs(definition.args) }
: {}),
insertionPoint: definition.insertionPoint!,
activeToolNames: [...activeToolNames],
},
}
: {}),
};
}
const runId = requiredString(input.runId, "runId");
if (input.action === "inspect") return { action: "inspect", runId };
const expectedRevision = input.expectedRevision;
if (!Number.isInteger(expectedRevision) || expectedRevision! < 0) {
throw new Error("expectedRevision is required");
}
const idempotencyKey = requiredString(
input.idempotencyKey,
"idempotencyKey",
);
if (input.action === "abort") {
return {
action: "abort",
runId,
expectedRevision: expectedRevision!,
idempotencyKey,
};
}
if (!input.decision) throw new Error("decision is required");
return {
action: "decide",
runId,
gateId: requiredString(input.gateId, "gateId"),
expectedRevision: expectedRevision!,
decision: input.decision as GateDecision,
idempotencyKey,
};
}
export interface WorkflowToolDetails {
action: WorkflowInput["action"];
runId: string;
revision: number;
status: WorkflowView["status"];
openGate?: {
id: string;
kind: string;
revision: number;
payload?: unknown;
};
continuation?: { command: string; runId: string };
planPath?: string;
worktreePath?: string;
assignmentCounts: Record<string, number>;
}
function boundedValue(value: unknown, budget = { characters: 24_000 }, depth = 0): unknown {
if (budget.characters <= 0) return "[truncated]";
if (typeof value === "string") {
const kept = [...value].slice(0, budget.characters).join("");
budget.characters -= [...kept].length;
return kept.length < value.length ? `${kept}[truncated]` : kept;
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (depth >= 6) return "[depth truncated]";
if (Array.isArray(value)) {
return value
.slice(0, 100)
.map((entry) => boundedValue(entry, budget, depth + 1));
}
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.slice(0, 100)
.map(([key, entry]) => [
key.slice(0, 200),
boundedValue(entry, budget, depth + 1),
]),
);
}
return String(value);
}
function boundedString(value: unknown, max = 2_000) {
if (typeof value !== "string") return undefined;
return [...value].slice(0, max).join("");
}
function compactGatePayload(view: WorkflowView) {
const gate = view.openGate;
const payload = gate?.payload;
if (!gate) return undefined;
if (gate.kind === "implementation-context") {
const context = view.implementationContext;
return context
? {
preparationId: context.preparationId,
planRevision: context.planRevision,
contextRevision: context.runRevision,
strategy: context.strategy,
status: context.status,
method: context.method,
outcome: context.outcome,
error: boundedString(context.error),
}
: undefined;
}
if (!payload || typeof payload !== "object") return payload;
const record = payload as Record<string, unknown>;
if (gate.kind === "questions") {
const questions = Array.isArray(record.questions) ? record.questions : [];
return {
questions: questions.slice(0, 20).flatMap((question) => {
if (!question || typeof question !== "object") return [];
const item = question as Record<string, unknown>;
const id = boundedString(item.id, 160);
const prompt = boundedString(item.prompt);
return id && prompt ? [{ id, prompt }] : [];
}),
};
}
if (gate.kind === "plan-approval") {
return {
planArtifact: boundedValue(record.planArtifact),
uiImpact: record.uiImpact,
uiImpactEvidence: Array.isArray(record.uiImpactEvidence)
? record.uiImpactEvidence.slice(0, 20).map((entry) => boundedString(entry, 500))
: [],
affectedUiSurfaces: Array.isArray(record.affectedUiSurfaces)
? record.affectedUiSurfaces.slice(0, 20).map((entry) => boundedString(entry, 500))
: [],
};
}
if (gate.kind === "prototype-acceptance") {
const candidates = Array.isArray(record.candidates) ? record.candidates : [];
return {
candidates: candidates.slice(-3).map((candidate) => {
if (!candidate || typeof candidate !== "object") return candidate;
const item = candidate as Record<string, unknown>;
return {
id: item.id,
revision: item.revision,
route: boundedString(item.route),
structuralRationale: boundedString(item.structuralRationale),
launchInstructions: boundedString(item.launchInstructions),
artifactPath: boundedString(item.artifactPath),
retainedArtifacts: Array.isArray(item.retainedArtifacts)
? item.retainedArtifacts
.slice(0, 10)
.map((entry) => boundedString(entry, 1_000))
: [],
};
}),
};
}
return boundedValue(record);
}
export function compactWorkflowDetails(
action: WorkflowInput["action"],
view: WorkflowView,
sessionId?: string,
): WorkflowToolDetails {
const assignmentCounts: Record<string, number> = {};
const gatePayload = compactGatePayload(view);
for (const assignment of view.assignments) {
assignmentCounts[assignment.status] =
(assignmentCounts[assignment.status] ?? 0) + 1;
}
const continuation = isValidContextContinuation(view, sessionId)
? { command: "/workflow continue", runId: view.runId }
: undefined;
return {
action,
runId: view.runId,
revision: view.revision,
status: view.status,
...(view.openGate
? {
openGate: {
id: view.openGate.id,
kind: view.openGate.kind,
revision: view.openGate.revision,
...(gatePayload !== undefined
? { payload: gatePayload }
: {}),
},
}
: {}),
...(continuation ? { continuation } : {}),
...(view.planArtifact ? { planPath: view.planArtifact.path } : {}),
...(view.workspace.root ? { worktreePath: view.workspace.root } : {}),
assignmentCounts,
};
}
function boundToolOutput(contents: string) {
const maxBytes = 48 * 1024;
if (Buffer.byteLength(contents, "utf8") <= maxBytes) return contents;
const marker = "\n[tool output truncated]";
const limit = maxBytes - Buffer.byteLength(marker, "utf8");
let bytes = 0;
const characters: string[] = [];
for (const character of contents) {
const width = Buffer.byteLength(character, "utf8");
if (bytes + width > limit) break;
characters.push(character);
bytes += width;
}
return `${characters.join("")}${marker}`;
}
function resultText(details: WorkflowToolDetails, label: string) {
const lines = [
`Workflow ${label}: ${details.status} at revision ${details.revision}.`,
];
if (details.openGate) {
lines.push(
`Open Gate: ${details.openGate.kind} (${details.openGate.id}). Obtain the user's explicit Decision before deciding it.`,
);
if (details.openGate.payload !== undefined) {
lines.push(`Gate context: ${JSON.stringify(details.openGate.payload)}`);
}
}
if (details.continuation) {
lines.push(`${details.continuation.command} ${details.continuation.runId}`);
}
if (details.worktreePath) lines.push(`Worktree: ${details.worktreePath}`);
if (details.planPath) lines.push(`Plan: ${details.planPath}`);
return boundToolOutput(lines.join("\n"));
}
async function cancelToolWorkflow(
runtime: ContextTransitionRuntime,
engine: Awaited<ReturnType<ContextTransitionRuntime["engineForContext"]>>,
input: WorkflowInput,
toolCallId: string,
) {
const runId = input.runId;
if (!runId) return;
const current = await runtime.store.loadRun(runId).catch(() => undefined);
if (
!current ||
current.status === "completed" ||
current.status === "failed" ||
current.status === "aborted"
) {
return;
}
await engine.execute({
action: "abort",
runId,
expectedRevision: current.revision,
idempotencyKey: `tool-cancel-${toolCallId}`,
}).catch(() => {});
}
function dirtySourceWarning(error: WorkspaceValidationError) {
const visibleChanges = error.sourceChanges.slice(0, 6);
const changes = visibleChanges.map(
(change) => `${change.status} ${change.path}`,
);
const omitted =
error.sourceChanges.length - visibleChanges.length +
error.sourceChangesOmitted;
if (omitted > 0) {
changes.push(`[${omitted} additional paths omitted]`);
}
return [
"The workflow worktree will start at the repository's current HEAD.",
"These staged, unstaged, or untracked source-only changes will be omitted:",
...changes,
"Continue only if the accepted plan should exclude those changes.",
].join("\n");
}
export function registerWorkflowTool(
pi: ExtensionAPI,
runtime: ContextTransitionRuntime,
) {
pi.registerTool({
name: "workflow",
label: "Pi Workflow",
description:
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.",
promptSnippet:
"Plan and build features through persisted Gates, worktrees, verification, and independent review",
promptGuidelines: [
"Use workflow when the user asks to plan or build a non-trivial feature through the managed workflow.",
"Never use workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.",
"Use workflow inspect before every state-changing call and pass its current revision and Gate ID.",
],
parameters: WorkflowParameters,
async execute(toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
const engine = await runtime.engineForContext(ctx);
const command = workflowCommandFromTool(params, pi.getActiveTools());
const executionSignal = signal ?? new AbortController().signal;
const cancellationError = () =>
executionSignal.reason instanceof Error
? executionSignal.reason
: new Error("workflow tool call was cancelled");
const operation = (async () => {
if (executionSignal.aborted) throw cancellationError();
if (command.action === "decide") {
return executeHostDecision(pi, engine, command);
}
const initial =
command.action === "start" &&
ctx.mode === "tui" &&
command.allowDirtySource === undefined
? { ...command, allowDirtySource: false }
: command;
try {
return await engine.execute(initial);
} catch (error) {
if (
command.action !== "start" ||
ctx.mode !== "tui" ||
command.allowDirtySource !== undefined ||
!(error instanceof WorkspaceValidationError) ||
error.code !== "dirty-source"
) {
throw error;
}
const confirmed = await workflowConfirm(
ctx,
"Dirty source checkout",
dirtySourceWarning(error),
);
if (executionSignal.aborted) throw cancellationError();
if (!confirmed) {
throw new Error("Workflow start cancelled because source changes would be omitted");
}
return engine.execute({ ...command, allowDirtySource: true });
}
})();
let rejectCancellation = (_error: unknown) => {};
const cancellation = new Promise<never>((_resolve, reject) => {
rejectCancellation = reject;
});
let cancellationWork: Promise<void> | undefined;
const onAbort = () => {
cancellationWork =
command.action === "start" || command.action === "start-analysis"
? engine.cancelStart(
command.idempotencyKey,
`tool-cancel-${toolCallId}`,
cancellationError(),
)
: cancelToolWorkflow(runtime, engine, params, toolCallId);
void cancellationWork.then(
() => rejectCancellation(cancellationError()),
() => rejectCancellation(cancellationError()),
);
};
executionSignal.addEventListener("abort", onAbort, { once: true });
if (executionSignal.aborted) onAbort();
let view: WorkflowView;
try {
view = await Promise.race([operation, cancellation]);
if (executionSignal.aborted) {
await cancellationWork;
throw cancellationError();
}
} catch (error) {
if (params.action === "inspect" && params.runId) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Workflow Run ${params.runId} could not be loaded: ${reason}`,
);
}
throw error;
} finally {
executionSignal.removeEventListener("abort", onAbort);
}
const details = compactWorkflowDetails(
params.action,
view,
ctx.sessionManager.getSessionId(),
);
return {
content: [
{ type: "text" as const, text: resultText(details, deriveRunLabel(view)) },
],
details,
};
},
});
}
+304
View File
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { CURSOR_MARKER, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
import {
createPlanReaderModel,
createPlanReaderState,
reducePlanReaderState,
renderPlanReader,
sanitizeTerminalText,
workflowEditor,
workflowInput,
WorkflowSelectionOverlay,
} from "./tui.ts";
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
};
const plan = [
"# Workflow plan",
"intro",
"## First section",
"first body",
"### Detail",
"detail body",
"## Last section",
"last body",
].join("\n");
test("selection overlay windows long lists, stays width-safe, and returns stable values", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Workflow Runs",
Array.from({ length: 30 }, (_, index) => ({
value: `run-${index}`,
label: `feature-${index}`,
description: index === 0 ? "waiting" : undefined,
})),
theme as never,
(value) => { selected = value; },
);
const initial = overlay.render(48);
assert.ok(initial.some((line) => line.includes("1-12 of 30")));
assert.ok(initial.every((line) => visibleWidth(line) <= 48));
overlay.handleInput("\x1b[F");
const last = overlay.render(48);
assert.ok(last.some((line) => line.includes("19-30 of 30")));
overlay.handleInput("\r");
assert.equal(selected, "run-29");
});
test("selection overlay budgets wrapped descriptions for short narrow terminals", () => {
let selected: string | undefined;
const overlay = new WorkflowSelectionOverlay(
"Short terminal",
Array.from({ length: 20 }, (_, index) => ({
value: `choice-${index}`,
label: `choice-${index}`,
description:
index === 19
? "A long selected description that wraps repeatedly without hiding navigation controls"
: undefined,
})),
theme as never,
(value) => { selected = value; },
8,
);
overlay.handleInput("\x1b[F");
const lines = overlay.render(28);
assert.ok(lines.length <= 8);
assert.ok(lines.every((line) => visibleWidth(line) <= 28));
assert.ok(lines.some((line) => line.includes("> choice-19")));
assert.ok(lines.some((line) => line.includes("enter select")));
overlay.handleInput("\x1b[A");
assert.ok(overlay.render(28).some((line) => line.includes("> choice-18")));
overlay.handleInput("\r");
assert.equal(selected, "choice-18");
});
type InteractiveOverlay = Component & Partial<Focusable>;
type OverlayFactory = (
tui: TUI,
overlayTheme: Theme,
keybindings: object,
done: (value: unknown) => void,
) => InteractiveOverlay;
function interactionContext(
interact: (component: InteractiveOverlay) => void,
rows = 24,
) {
const tui = {
requestRender() {},
terminal: { columns: 80, rows },
} as unknown as TUI;
return {
mode: "tui",
ui: {
async custom(factory: OverlayFactory) {
let completed: unknown;
const component = factory(
tui,
theme as unknown as Theme,
{},
(value) => { completed = value; },
);
interact(component);
return completed;
},
},
} as unknown as ExtensionContext;
}
test("input overlay propagates focus, renders a cursor marker, and handles input", async () => {
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(48).join("\n");
component.focused = true;
component.handleInput?.("approved");
focused = component.render(48).join("\n");
component.handleInput?.("\r");
}),
"Answer",
"Provide a value",
);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /approved/);
assert.equal(result, "approved");
});
test("input overlay keeps focused input and controls visible below a long prompt", async () => {
const prompt = Array.from(
{ length: 20 },
(_, index) => `long prompt segment ${index}`,
).join(" ");
let unfocused = "";
let focused = "";
const result = await workflowInput(
interactionContext((component) => {
unfocused = component.render(40).join("\n");
component.focused = true;
component.handleInput?.("yes");
focused = component.render(40).join("\n");
component.handleInput?.("\r");
}, 8),
"Short terminal",
prompt,
);
assert.ok(unfocused.split("\n").length <= 5);
assert.equal(unfocused.includes(CURSOR_MARKER), false);
assert.ok(focused.split("\n").length <= 5);
assert.ok(focused.split("\n").every((line) => visibleWidth(line) <= 40));
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /yes/);
assert.match(focused, /enter submit esc back/);
assert.match(focused, /…/);
assert.equal(result, "yes");
});
test("editor overlay propagates focus and Escape resolves through the wrapper", async () => {
let focused = "";
const result = await workflowEditor(
interactionContext((component) => {
component.focused = true;
component.handleInput?.("x");
focused = component.render(56).join("\n");
component.handleInput?.("\x1b");
}),
"Feedback",
"Revise the plan",
"draft",
);
assert.equal(focused.includes(CURSOR_MARKER), true);
assert.match(focused, /draft/);
assert.equal(result, undefined);
});
test("plan reader parses H1-H3 and tracks bounded navigation state", () => {
const model = createPlanReaderModel("example", plan);
assert.deepEqual(
model.headings.map(({ level, text, line }) => ({ level, text, line })),
[
{ level: 1, text: "Workflow plan", line: 0 },
{ level: 2, text: "First section", line: 2 },
{ level: 3, text: "Detail", line: 4 },
{ level: 2, text: "Last section", line: 6 },
],
);
let state = createPlanReaderState();
state = reducePlanReaderState(model, state, "up", { width: 60, height: 24 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "page-down", { width: 60, height: 12 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "home", { width: 60, height: 12 });
assert.equal(state.topRow, 0);
state = reducePlanReaderState(model, state, "end", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
state = reducePlanReaderState(model, state, "down", { width: 60, height: 24 });
assert.equal(state.topRow, 7);
});
test("plan reader renders outline at 80 columns and one pane below 80", () => {
const model = createPlanReaderModel("example", plan);
const wide = renderPlanReader(model, { topRow: 4 }, { width: 80, height: 24 });
assert.ok(wide.some((line) => line.includes("> Detail")));
assert.ok(wide.some((line) => line.includes("detail body")));
assert.ok(wide.every((line) => visibleWidth(line) <= 80));
assert.ok(wide.length <= 24);
const narrow = renderPlanReader(model, { topRow: 4 }, { width: 60, height: 12 });
assert.match(narrow[0] ?? "", /Detail/);
assert.equal(narrow.some((line) => line.includes("> Detail")), false);
assert.ok(narrow.every((line) => visibleWidth(line) <= 60));
assert.ok(narrow.length <= 12);
});
test("wide outline windows enough headings to keep the current section visible", () => {
const contents = Array.from(
{ length: 40 },
(_, index) => `## Section ${index}\nbody ${index}`,
).join("\n");
const model = createPlanReaderModel("many sections", contents);
const lines = renderPlanReader(
model,
{ topRow: 70 },
{ width: 80, height: 12 },
);
assert.ok(lines.some((line) => />\s+Section 35/.test(line)));
assert.equal(lines.some((line) => line.includes("Section 0")), false);
});
test("plan reader reports truthful minimum size below either boundary", () => {
const model = createPlanReaderModel("example", plan);
for (const dimensions of [
{ width: 47, height: 11 },
{ width: 47, height: 24 },
{ width: 80, height: 11 },
]) {
const lines = renderPlanReader(model, createPlanReaderState(), dimensions);
assert.ok(lines.some((line) => line.includes("needs 48x12")));
assert.ok(
lines.some((line) =>
line.includes(`current terminal is ${dimensions.width}x${dimensions.height}`),
),
);
assert.ok(lines.every((line) => visibleWidth(line) <= dimensions.width));
assert.ok(lines.length <= dimensions.height);
}
});
test("reader sanitizes terminal control sequences before rendering", () => {
const unsafe = "# Safe\n\x1b[31mred\x1b[0m\n\x1b]8;;https://bad.invalid\x07link\x1b]8;;\x07\nend\u0000";
const sanitized = sanitizeTerminalText(unsafe);
assert.equal(sanitized.includes("\x1b"), false);
assert.equal(sanitized.includes("\u0000"), false);
const model = createPlanReaderModel("unsafe", unsafe);
const lines = renderPlanReader(model, createPlanReaderState(), { width: 48, height: 12 });
assert.equal(lines.join("\n").includes("\x1b"), false);
assert.ok(lines.every((line) => visibleWidth(line) <= 48));
});
test("plan reader reaches every wrapped display row before following content", () => {
const longLine = Array.from(
{ length: 40 },
(_, index) => `chunk-${String(index).padStart(2, "0")}`,
).join(" ");
const model = createPlanReaderModel(
"wrapped rows",
`# Wrapped section\n${longLine}\nfollowing-content`,
);
const expectedTopRows = [
"# Wrapped section",
...wrapTextWithAnsi(longLine, 46),
"following-content",
];
let state = createPlanReaderState();
for (const expected of expectedTopRows) {
const frame = renderPlanReader(model, state, { width: 48, height: 12 });
assert.equal(frame[2]?.trim(), expected);
assert.match(frame[0] ?? "", /Wrapped sect/);
state = reducePlanReaderState(
model,
state,
"down",
{ width: 48, height: 12 },
);
}
assert.ok(expectedTopRows.length > 5);
});
test("reader only includes the active viewport near document end", () => {
const contents = Array.from({ length: 200 }, (_, index) => `line-${index}`).join("\n");
const model = createPlanReaderModel("large", contents);
const lines = renderPlanReader(model, { topRow: 190 }, { width: 60, height: 12 });
assert.ok(lines.some((line) => line.includes("line-190")));
assert.equal(lines.some((line) => line.includes("line-20")), false);
assert.ok(lines.length <= 12);
});
+702
View File
@@ -0,0 +1,702 @@
import type {
ExtensionContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
Editor,
type EditorTheme,
Input,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type Focusable,
type TUI,
} from "@earendil-works/pi-tui";
export interface OverlayChoice<T> {
value: T;
label: string;
description?: string;
}
function sanitizeLine(value: string) {
return value
.replace(
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g,
"",
)
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
.replaceAll("\t", " ");
}
export function sanitizeTerminalText(value: string) {
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n");
}
function fit(value: string, width: number) {
const fitted = truncateToWidth(value, Math.max(1, width), "");
return value.includes("\x1b") ? fitted : sanitizeLine(fitted);
}
function pad(value: string, width: number) {
const bounded = fit(value, width);
return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
}
function card(
theme: Theme,
title: string,
contents: string[],
width: number,
) {
const innerWidth = Math.max(1, width - 2);
const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth);
const titleWidth = visibleWidth(safeTitle);
const trailing = Math.max(0, innerWidth - titleWidth);
return [
`${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`,
...contents.map(
(line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`,
),
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
].map((line) => fit(line, width));
}
export class WorkflowSelectionOverlay<T> implements Component {
private selected = 0;
private readonly title: string;
private readonly choices: OverlayChoice<T>[];
private readonly theme: Theme;
private readonly done: (value: T | undefined) => void;
private readonly maxRows: number;
constructor(
title: string,
choices: OverlayChoice<T>[],
theme: Theme,
done: (value: T | undefined) => void,
maxRows = 24,
) {
this.title = title;
this.choices = choices;
this.theme = theme;
this.done = done;
this.maxRows = Math.max(4, Math.floor(maxRows));
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done(undefined);
} else if (matchesKey(data, Key.up)) {
this.selected = Math.max(0, this.selected - 1);
} else if (matchesKey(data, Key.down)) {
this.selected = Math.min(this.choices.length - 1, this.selected + 1);
} else if (matchesKey(data, Key.home)) {
this.selected = 0;
} else if (matchesKey(data, Key.end)) {
this.selected = Math.max(0, this.choices.length - 1);
} else if (matchesKey(data, Key.enter)) {
const choice = this.choices[this.selected];
if (choice) this.done(choice.value);
}
}
render(width: number) {
const bodyRows = Math.max(2, this.maxRows - 2);
const showLeadingSpace = bodyRows >= 5;
const showPagination = this.choices.length > 1 && bodyRows >= 3;
const selectedChoice = this.choices[this.selected];
const descriptionWidth = Math.max(1, width - 8);
const selectedDescription = selectedChoice?.description
? sanitizeTerminalText(selectedChoice.description)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, descriptionWidth);
return wrapped.length > 0 ? wrapped : [""];
})
: [];
const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination);
const choiceRows = Math.max(1, bodyRows - fixedRows);
const descriptionRows = Math.min(
selectedDescription.length,
Math.max(0, Math.floor((choiceRows - 1) / 2)),
);
const maxVisible = Math.max(
1,
Math.min(12, this.choices.length || 1, choiceRows - descriptionRows),
);
const start = Math.max(
0,
Math.min(
this.selected - Math.floor(maxVisible / 2),
Math.max(0, this.choices.length - maxVisible),
),
);
const body: string[] = showLeadingSpace ? [""] : [];
for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) {
const index = start + offset;
const selected = index === this.selected;
const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`;
body.push(
selected
? this.theme.bg("selectedBg", this.theme.fg("text", text))
: this.theme.fg("text", text),
);
if (selected) {
for (const line of selectedDescription.slice(0, descriptionRows)) {
body.push(this.theme.fg("muted", ` ${line}`));
}
}
}
if (this.choices.length === 0) {
body.push(this.theme.fg("muted", " No choices available"));
}
if (showPagination && this.choices.length > maxVisible) {
body.push(
this.theme.fg(
"dim",
` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`,
),
);
}
body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back"));
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {}
}
abstract class FocusedTextOverlay implements Component, Focusable {
private _focused = false;
abstract readonly child: Input | Editor;
get focused() {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.child.focused = value;
}
abstract render(width: number): string[];
abstract handleInput(data: string): void;
abstract invalidate(): void;
}
class WorkflowInputOverlay extends FocusedTextOverlay {
readonly child = new Input();
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
private readonly maxRows: number;
constructor(
title: string,
prompt: string,
theme: Theme,
done: (value: string | undefined) => void,
maxRows: number,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
this.maxRows = Math.max(4, Math.floor(maxRows));
this.child.onSubmit = (value) => done(value);
this.child.onEscape = () => done(undefined);
}
handleInput(data: string) {
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const wrappedPrompt = sanitizeTerminalText(this.prompt)
.split("\n")
.flatMap((line) => {
const wrapped = wrapTextWithAnsi(line, innerWidth);
return wrapped.length > 0 ? wrapped : [""];
});
const bodyRows = this.maxRows - 2;
const showLeadingSpace = bodyRows >= 5;
const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace));
const prompt = wrappedPrompt.slice(0, promptRows);
if (wrappedPrompt.length > promptRows && prompt.length > 0) {
const last = prompt.length - 1;
prompt[last] = fit(
`${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`,
innerWidth,
);
}
const body = [
...(showLeadingSpace ? [""] : []),
...prompt.map((line) => ` ${line}`),
` ${this.child.render(innerWidth)[0] ?? ""}`,
this.theme.fg("dim", " enter submit esc back"),
];
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
}
invalidate() {
this.child.invalidate();
}
}
class WorkflowEditorOverlay extends FocusedTextOverlay {
readonly child: Editor;
private readonly title: string;
private readonly prompt: string;
private readonly theme: Theme;
constructor(
tui: TUI,
title: string,
prompt: string,
initial: string,
theme: Theme,
done: (value: string | undefined) => void,
) {
super();
this.title = title;
this.prompt = prompt;
this.theme = theme;
const editorTheme: EditorTheme = {
borderColor: (value) => theme.fg("borderAccent", value),
selectList: {
selectedPrefix: (value) => theme.fg("accent", value),
selectedText: (value) => theme.fg("accent", value),
description: (value) => theme.fg("muted", value),
scrollInfo: (value) => theme.fg("dim", value),
noMatch: (value) => theme.fg("warning", value),
},
};
this.child = new Editor(tui, editorTheme, { paddingX: 0 });
this.child.setText(initial);
this.child.onSubmit = (value) => done(value);
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) return;
this.child.handleInput(data);
}
render(width: number) {
const innerWidth = Math.max(1, width - 4);
const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth);
return card(
this.theme,
this.title,
["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")],
width,
);
}
invalidate() {
this.child.invalidate();
}
}
function assertTui(ctx: ExtensionContext) {
if (ctx.mode !== "tui") {
throw new Error("workflow overlays require TUI mode");
}
}
export async function workflowSelect<T>(
ctx: ExtensionContext,
title: string,
choices: OverlayChoice<T>[],
) {
assertTui(ctx);
return ctx.ui.custom<T | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowSelectionOverlay(
title,
choices,
theme,
done,
Math.floor(tui.terminal.rows * 0.8),
);
return {
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "center",
width: "70%",
minWidth: 36,
maxHeight: "80%",
margin: 1,
},
},
);
}
export function workflowConfirm(
ctx: ExtensionContext,
title: string,
message: string,
) {
return workflowSelect(ctx, title, [
{ value: true, label: "Confirm", description: message },
{ value: false, label: "Cancel" },
]).then((value) => value === true);
}
export async function workflowInput(
ctx: ExtensionContext,
title: string,
prompt: string,
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowInputOverlay(
title,
prompt,
theme,
done,
Math.max(4, Math.floor(tui.terminal.rows * 0.7)),
);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 },
},
);
}
export async function workflowEditor(
ctx: ExtensionContext,
title: string,
prompt: string,
initial = "",
) {
assertTui(ctx);
return ctx.ui.custom<string | undefined>(
(tui, theme, _keybindings, done) => {
const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done);
return {
get focused() {
return overlay.focused;
},
set focused(value: boolean) {
overlay.focused = value;
},
render: (width) => overlay.render(width),
invalidate: () => overlay.invalidate(),
handleInput: (data) => {
if (matchesKey(data, Key.escape)) done(undefined);
else overlay.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 },
},
);
}
export interface PlanHeading {
level: 1 | 2 | 3;
text: string;
line: number;
}
export interface PlanReaderModel {
title: string;
lines: string[];
headings: PlanHeading[];
}
export interface PlanReaderState {
topRow: number;
}
export type PlanReaderAction =
| "up"
| "down"
| "page-up"
| "page-down"
| "home"
| "end";
interface PlanDisplayRow {
text: string;
sourceLine: number;
}
export function createPlanReaderModel(title: string, contents: string): PlanReaderModel {
const lines = sanitizeTerminalText(contents).split("\n");
const headings = lines.flatMap((line, index): PlanHeading[] => {
const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
return match
? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }]
: [];
});
return { title: sanitizeLine(title), lines, headings };
}
export function createPlanReaderState(): PlanReaderState {
return { topRow: 0 };
}
function readerBodyHeight(height: number) {
return Math.max(1, height - 4);
}
function readerOutlineWidth(width: number) {
return Math.max(24, Math.min(32, Math.floor(width * 0.34)));
}
function readerDocumentWidth(width: number) {
return width < 80
? Math.max(1, width - 2)
: Math.max(1, width - readerOutlineWidth(width) - 3);
}
function displayRows(model: PlanReaderModel, width: number) {
return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => {
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({
text,
sourceLine,
}));
});
}
export function reducePlanReaderState(
model: PlanReaderModel,
state: PlanReaderState,
action: PlanReaderAction,
dimensions: { width: number; height: number },
): PlanReaderState {
const rows = displayRows(model, readerDocumentWidth(dimensions.width));
const last = Math.max(0, rows.length - 1);
const page = Math.max(1, readerBodyHeight(dimensions.height) - 1);
const delta =
action === "up"
? -1
: action === "down"
? 1
: action === "page-up"
? -page
: action === "page-down"
? page
: 0;
const topRow =
action === "home"
? 0
: action === "end"
? last
: Math.max(0, Math.min(last, state.topRow + delta));
return { topRow };
}
function currentHeading(model: PlanReaderModel, sourceLine: number) {
return [...model.headings].reverse().find((heading) => heading.line <= sourceLine);
}
function viewportRows(
rows: PlanDisplayRow[],
topRow: number,
height: number,
) {
return rows.slice(topRow, topRow + readerBodyHeight(height));
}
export function renderPlanReader(
model: PlanReaderModel,
state: PlanReaderState,
dimensions: { width: number; height: number },
theme?: Theme,
) {
const { width, height } = dimensions;
if (width < 48 || height < 12) {
const lines = [
"",
"terminal too small",
"",
"workflow plan needs 48x12",
`current terminal is ${width}x${height}`,
"",
"resize the terminal or press esc",
];
const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2));
return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height);
}
const documentWidth = readerDocumentWidth(width);
const rows = displayRows(model, documentWidth);
const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow));
const sourceLine = rows[topRow]?.sourceLine ?? 0;
const heading = currentHeading(model, sourceLine);
const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`;
const headingLabel = width < 80 && heading ? ` · ${heading.text}` : "";
const header = fit(` workflow plan · ${model.title}${headingLabel} · ${position}`, width);
const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width);
const bodyHeight = readerBodyHeight(height);
const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) =>
theme ? theme.fg(token, value) : value;
const viewport = viewportRows(rows, topRow, height);
if (width < 80) {
const body = viewport.map((row) => {
const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const;
return fit(` ${paint(token, row.text)}`, width);
});
while (body.length < bodyHeight) body.push("");
return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height);
}
const outlineWidth = readerOutlineWidth(width);
const gap = 3;
const body = viewport.map((row) => row.text);
const selectedHeading = heading?.line;
const selectedHeadingIndex = Math.max(
0,
model.headings.findIndex((entry) => entry.line === selectedHeading),
);
const outlineStart = Math.max(
0,
Math.min(
selectedHeadingIndex - Math.floor(bodyHeight / 2),
Math.max(0, model.headings.length - bodyHeight),
),
);
const outline = model.headings
.slice(outlineStart, outlineStart + bodyHeight)
.map((entry) => {
const prefix = entry.line === selectedHeading ? "> " : " ";
const indent = " ".repeat(entry.level - 1);
const text = `${prefix}${indent}${entry.text}`;
return entry.line === selectedHeading
? paint("accent", text)
: paint(entry.level === 1 ? "text" : "muted", text);
});
const renderedRows = Array.from({ length: bodyHeight }, (_, index) => {
const left = pad(outline[index] ?? "", outlineWidth);
const right = fit(body[index] ?? "", documentWidth);
return fit(`${left}${" ".repeat(gap)}${right}`, width);
});
return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height);
}
class PlanReaderComponent implements Component {
private state = createPlanReaderState();
private width: number;
private readonly tui: TUI;
private readonly model: PlanReaderModel;
private readonly theme: Theme;
private readonly done: () => void;
constructor(
tui: TUI,
model: PlanReaderModel,
theme: Theme,
done: () => void,
) {
this.tui = tui;
this.width = tui.terminal.columns;
this.model = model;
this.theme = theme;
this.done = done;
}
handleInput(data: string) {
if (matchesKey(data, Key.escape)) {
this.done();
return;
}
const action = matchesKey(data, Key.up)
? "up"
: matchesKey(data, Key.down)
? "down"
: matchesKey(data, Key.pageUp)
? "page-up"
: matchesKey(data, Key.pageDown)
? "page-down"
: matchesKey(data, Key.home)
? "home"
: matchesKey(data, Key.end)
? "end"
: undefined;
if (action) {
this.state = reducePlanReaderState(
this.model,
this.state,
action,
{ width: this.width, height: this.tui.terminal.rows },
);
}
}
render(width: number) {
this.width = width;
return renderPlanReader(
this.model,
this.state,
{ width, height: this.tui.terminal.rows },
this.theme,
);
}
invalidate() {}
}
export async function workflowPlanReader(
ctx: ExtensionContext,
title: string,
contents: string,
) {
assertTui(ctx);
const model = createPlanReaderModel(title, contents);
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const reader = new PlanReaderComponent(tui, model, theme, done);
return {
render: (width) => reader.render(width),
invalidate: () => reader.invalidate(),
handleInput: (data) => {
reader.handleInput(data);
tui.requestRender();
},
};
},
{
overlay: true,
overlayOptions: {
anchor: "top-left",
width: "100%",
maxHeight: "100%",
margin: 0,
},
},
);
}
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { WorkflowFacts } from "./domain.ts";
import { enforceUiImpact } from "./ui-policy.ts";
function facts(overrides: Partial<WorkflowFacts> = {}): WorkflowFacts {
return {
stage: "design",
taskKind: "feature",
requestTerms: [],
candidatePaths: [],
languages: ["typescript"],
tags: [],
riskTags: [],
...overrides,
};
}
test("objective non-trivial UI signals cannot be downgraded", () => {
const result = enforceUiImpact({
plannerImpact: "minor",
plannerEvidence: ["Planner expected a small change"],
affectedSurfaces: ["Application shell"],
facts: facts({ requestTerms: ["redesign", "navigation"] }),
});
assert.equal(result.impact, "non-trivial");
assert.equal(result.objectiveImpact, "non-trivial");
assert.ok(result.evidence.some((entry) => entry.includes("redesign")));
});
test("layout paths and accessibility evidence require prototypes", () => {
const result = enforceUiImpact({
plannerImpact: "none",
plannerEvidence: [],
affectedSurfaces: [],
facts: facts({
candidatePaths: ["src/routes/layout.svelte"],
riskTags: ["accessibility"],
}),
});
assert.equal(result.impact, "non-trivial");
assert.equal(result.evidence.length, 2);
});
test("common pages, forms, modals, sidebars, and settings changes cannot bypass prototypes", () => {
for (const signal of ["page", "form", "modal", "sidebar", "settings"]) {
const result = enforceUiImpact({
plannerImpact: "minor",
plannerEvidence: [],
affectedSurfaces: [],
facts: facts({ requestTerms: ["add", signal] }),
});
assert.equal(result.impact, "non-trivial", signal);
}
assert.equal(
enforceUiImpact({
plannerImpact: "minor",
plannerEvidence: [],
affectedSurfaces: [],
facts: facts({ candidatePaths: ["src/routes/account/+page.svelte"] }),
}).impact,
"non-trivial",
);
});
test("backend workflow terminology does not imply a UI prototype", () => {
const result = enforceUiImpact({
plannerImpact: "none",
plannerEvidence: ["Backend orchestration only"],
affectedSurfaces: [],
facts: facts({
requestTerms: ["workflow", "queue"],
candidatePaths: ["src/server/workflow.ts"],
}),
});
assert.equal(result.impact, "none");
});
test("configuration files named settings do not imply a UI prototype", () => {
const result = enforceUiImpact({
plannerImpact: "none",
plannerEvidence: ["Configuration inventory only"],
affectedSurfaces: [],
facts: facts({
candidatePaths: [
"/home/user/.pi/agent/settings.json",
"config/settings.yaml",
],
tags: ["configuration", "security"],
}),
});
assert.equal(result.impact, "none");
assert.equal(result.objectiveImpact, "none");
});
test("generic frontend evidence does not inflate a minor change", () => {
const result = enforceUiImpact({
plannerImpact: "minor",
plannerEvidence: ["Adds one field"],
affectedSurfaces: ["Dashboard"],
facts: facts({
candidatePaths: ["src/routes/dashboard.ts"],
tags: ["frontend", "ui"],
}),
});
assert.equal(result.impact, "minor");
});
+111
View File
@@ -0,0 +1,111 @@
import type { UiImpact, WorkflowFacts } from "./domain.ts";
const IMPACT_RANK: Record<UiImpact, number> = {
none: 0,
minor: 1,
"non-trivial": 2,
};
const NON_TRIVIAL_TERMS = new Set([
"accessibility",
"a11y",
"component",
"dialog",
"editor",
"form",
"layout",
"modal",
"navigation",
"onboarding",
"page",
"redesign",
"responsive",
"settings",
"sidebar",
"wizard",
]);
const NON_TRIVIAL_PATH_PARTS = [
"/+page.",
"/dialog.",
"/form.",
"/layout.",
"/modal.",
"/navigation.",
"/nav.",
"/settings.",
"/shell.",
"/sidebar.",
"/accessibility/",
"/onboarding/",
"/settings/",
];
const UI_SOURCE_SUFFIXES = [
".astro",
".css",
".html",
".jsx",
".less",
".scss",
".svelte",
".tsx",
".vue",
];
export interface UiImpactPolicyInput {
plannerImpact: UiImpact;
plannerEvidence: string[];
affectedSurfaces: string[];
facts: WorkflowFacts;
}
export interface UiImpactPolicyResult {
impact: UiImpact;
evidence: string[];
affectedSurfaces: string[];
objectiveImpact: UiImpact;
}
function objectiveEvidence(facts: WorkflowFacts) {
const evidence: string[] = [];
for (const term of facts.requestTerms) {
if (NON_TRIVIAL_TERMS.has(term.toLowerCase())) {
evidence.push(`Request includes non-trivial UI signal: ${term}`);
}
}
for (const candidatePath of facts.candidatePaths) {
const normalized = `/${candidatePath.toLowerCase().replaceAll("\\", "/")}`;
const isUiSource = UI_SOURCE_SUFFIXES.some((suffix) =>
normalized.endsWith(suffix),
);
if (
isUiSource &&
NON_TRIVIAL_PATH_PARTS.some((part) => normalized.includes(part))
) {
evidence.push(`Affected path has non-trivial UI responsibility: ${candidatePath}`);
}
}
for (const tag of [...facts.tags, ...facts.riskTags]) {
if (NON_TRIVIAL_TERMS.has(tag.toLowerCase())) {
evidence.push(`Repository evidence includes non-trivial UI signal: ${tag}`);
}
}
return evidence;
}
export function enforceUiImpact(input: UiImpactPolicyInput): UiImpactPolicyResult {
const objective = objectiveEvidence(input.facts);
const objectiveImpact: UiImpact =
objective.length > 0 ? "non-trivial" : input.plannerImpact;
const impact =
IMPACT_RANK[objectiveImpact] > IMPACT_RANK[input.plannerImpact]
? objectiveImpact
: input.plannerImpact;
return {
impact,
objectiveImpact,
evidence: [...new Set([...input.plannerEvidence, ...objective])],
affectedSurfaces: [...new Set(input.affectedSurfaces)],
};
}
@@ -0,0 +1,922 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
access,
mkdir,
mkdtemp,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { deflateSync } from "node:zlib";
import {
ApprovedHostCommandExecutor,
ExecFileCommandRunner,
FilesystemPrototypeValidator,
GitStateProbe,
GitWorkspaceManager,
INTERNAL_GIT_TIMEOUT_MS,
MutationScope,
WorkspaceSafetyError,
WorkspaceValidationError,
type CommandResult,
type CommandRunner,
type CommandRunOptions,
} from "./workspace.ts";
const execFileAsync = promisify(execFile);
function crc32(contents: Uint8Array) {
let crc = 0xffffffff;
for (const byte of contents) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer) {
const name = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.byteLength);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
return Buffer.concat([length, name, data, checksum]);
}
function grayscalePng(width: number, height: number, value: number) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 0;
const rows = Buffer.alloc((width + 1) * height);
for (let row = 0; row < height; row++) {
rows[row * (width + 1)] = 0;
rows.fill(value, row * (width + 1) + 1, (row + 1) * (width + 1));
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(rows)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(
path.join(tmpdir(), "workflow-workspace-"),
);
try {
await run(directory);
} finally {
await rm(directory, { recursive: true, force: true });
}
}
class FakeRunner implements CommandRunner {
readonly calls: Array<{
cwd: string;
executable: string;
args: string[];
options?: CommandRunOptions;
}> = [];
private readonly respond: (args: string[]) => CommandResult;
constructor(respond: (args: string[]) => CommandResult) {
this.respond = respond;
}
async run(
cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
) {
this.calls.push({ cwd, executable, args: [...args], options });
return this.respond(args);
}
}
function result(code = 0, stdout = "", stderr = ""): CommandResult {
return { code, stdout, stderr };
}
function prepareResponses(
repository: string,
options: {
dirty?: boolean;
unmerged?: boolean;
ignored?: boolean;
branchExists?: boolean;
} = {},
) {
return (args: string[]) => {
const command = args.join(" ");
if (command === "rev-parse --show-toplevel")
return result(0, `${repository}\n`);
if (command === "status --porcelain=v1 -z --untracked-files=all") {
if (options.unmerged) return result(0, "UU src/conflict.ts\0");
return result(
0,
options.dirty ? " M src/file.ts\0?? src/new-file.ts\0" : "",
);
}
if (command.startsWith("check-ignore ")) {
return result(options.ignored === false ? 1 : 0);
}
if (command.startsWith("show-ref ")) {
return result(options.branchExists ? 0 : 1);
}
if (command === "rev-parse HEAD") return result(0, "abc123\n");
if (command.startsWith("worktree add ")) return result();
throw new Error(`Unexpected Git command: ${command}`);
};
}
test("mutation ownership rejects traversal, sibling prefixes, and symlinks", async () => {
await withTempDir(async (directory) => {
const root = path.join(directory, "worktree");
const outside = path.join(directory, "outside");
await mkdir(path.join(root, "src"), { recursive: true });
await mkdir(outside, { recursive: true });
const scope = await MutationScope.create(root, ["src"]);
assert.equal(
await scope.assertWritePath("src/new/file.ts"),
path.join(root, "src/new/file.ts"),
);
await scope.mkdir("src/new");
await scope.writeFile("src/new/file.ts", "safe\n");
assert.equal(await readFile(path.join(root, "src/new/file.ts"), "utf8"), "safe\n");
await assert.rejects(scope.assertWritePath("src-other/file.ts"), /outside Assignment ownership/);
await assert.rejects(scope.assertWritePath("../outside/file.ts"), /outside Assignment ownership/);
await symlink(outside, path.join(root, "src", "linked"));
await assert.rejects(
scope.assertWritePath("src/linked/file.ts"),
/symbolic link|outside the worktree/,
);
await symlink(outside, path.join(root, "owned-link"));
const linkedScope = await MutationScope.create(root, ["owned-link"]);
await assert.rejects(
linkedScope.assertWritePath("owned-link/file.ts"),
/symbolic link|outside the worktree/,
);
});
});
test("prototype validation requires real candidates and browser evidence, then proves cleanup", async () => {
await withTempDir(async (directory) => {
const candidate = path.join(directory, "prototypes", "candidate-1");
await mkdir(candidate, { recursive: true });
await writeFile(path.join(candidate, "index.html"), "<main>Prototype</main>", "utf8");
await writeFile(
path.join(candidate, "desktop.png"),
grayscalePng(1440, 900, 200),
);
await writeFile(
path.join(candidate, "mobile.png"),
grayscalePng(390, 844, 100),
);
await writeFile(
path.join(candidate, "desktop.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 1440, height: 900 },
screenshotPath: "prototypes/candidate-1/desktop.png",
}),
"utf8",
);
await writeFile(
path.join(candidate, "mobile.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 390, height: 844 },
screenshotPath: "prototypes/candidate-1/mobile.png",
}),
"utf8",
);
const validator = new FilesystemPrototypeValidator();
const validation = await validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: [
"prototypes/candidate-1/mobile.json",
"prototypes/candidate-1/desktop.json",
],
},
});
assert.equal(validation.artifactFiles.length, 4);
assert.match(validation.visualDigest, /^[a-f0-9]{64}$/);
await writeFile(
path.join(candidate, "fake.png"),
Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
Buffer.from("not an image"),
]),
);
await writeFile(
path.join(candidate, "fake.json"),
JSON.stringify({
route: "/prototype?variant=1",
result: "passed",
viewport: { width: 390, height: 844 },
screenshotPath: "prototypes/candidate-1/fake.png",
}),
"utf8",
);
await assert.rejects(
validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: ["prototypes/candidate-1/fake.json"],
},
}),
/not a decodable PNG/,
);
await assert.rejects(
validator.validateCandidate({
worktreeRoot: directory,
assignment: { ownedPaths: ["prototypes/candidate-1"] },
output: {
path: "prototypes/candidate-1",
route: "/prototype?variant=1",
browserEvidence: ["prototypes/candidate-1/missing.json"],
},
}),
/missing|ENOENT/,
);
await assert.rejects(
validator.validateCleanup({
worktreeRoot: directory,
candidates: [{ path: "prototypes/candidate-1" }],
}),
/remains after cleanup/,
);
await rm(candidate, { recursive: true });
await validator.validateCleanup({
worktreeRoot: directory,
candidates: [{ path: "prototypes/candidate-1" }],
});
});
});
test("Git probe and approved host commands enforce identity and ownership", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await mkdir(path.join(directory, "other"), { recursive: true });
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
await writeFile(path.join(directory, "other", "file.ts"), "before\n", "utf8");
await writeFile(
path.join(directory, "mutate.cjs"),
'require("node:fs").writeFileSync(process.argv[2], "after\\n")',
"utf8",
);
await writeFile(
path.join(directory, "src", "bytecode_module.py"),
"VALUE = 1\n",
"utf8",
);
await writeFile(
path.join(directory, "src", "check_bytecode.py"),
"import bytecode_module\nassert bytecode_module.VALUE == 1\n",
"utf8",
);
await writeFile(
path.join(directory, "check-offline.cjs"),
'process.exit(process.env.PI_OFFLINE === "1" ? 0 : 1)',
"utf8",
);
await writeFile(
path.join(directory, "spawn-hanging-child.cjs"),
[
'const { spawn } = require("node:child_process");',
'const { writeFileSync } = require("node:fs");',
'const child = spawn(process.execPath, ["delayed-write.cjs"], { stdio: "ignore" });',
'writeFileSync("src/timeout-pids.json", JSON.stringify([process.pid, child.pid]));',
"setInterval(() => {}, 1_000);",
].join("\n"),
"utf8",
);
await writeFile(
path.join(directory, "delayed-write.cjs"),
[
'const { writeFileSync } = require("node:fs");',
'process.on("SIGTERM", () => {});',
'setTimeout(() => writeFileSync("src/delayed.txt", "late\\n"), 1_200);',
"setInterval(() => {}, 1_000);",
].join("\n"),
"utf8",
);
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
const probe = new GitStateProbe();
const initial = await probe.capture(directory);
assert.equal(initial.changedPaths.length, 0);
const executor = new ApprovedHostCommandExecutor();
const allowed = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["mutate.cjs", "src/file.ts"],
});
assert.equal(allowed.result.code, 0);
assert.deepEqual(allowed.after.changedPaths, ["src/file.ts"]);
const bytecodeDisabled = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "python3",
args: ["src/check_bytecode.py"],
});
assert.equal(bytecodeDisabled.result.code, 0);
await assert.rejects(
access(path.join(directory, "src", "__pycache__")),
/ENOENT/,
);
const offline = await executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["PI_OFFLINE=1", process.execPath, "check-offline.cjs"],
});
assert.equal(offline.result.code, 0);
const boundedExecutor = new ApprovedHostCommandExecutor(
new ExecFileCommandRunner(),
undefined,
250,
);
const startedAt = Date.now();
const timedOut = await boundedExecutor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["spawn-hanging-child.cjs"],
});
assert.equal(timedOut.result.code, 124);
assert.match(timedOut.result.stderr, /timed out after 250ms/);
assert.ok(Date.now() - startedAt < 2_500);
const timedOutProcessIds = JSON.parse(
await readFile(path.join(directory, "src", "timeout-pids.json"), "utf8"),
) as number[];
for (const processId of timedOutProcessIds) {
assert.throws(
() => process.kill(processId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
await assert.rejects(
access(path.join(directory, "src", "delayed.txt")),
/ENOENT/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["OTHER=value", process.execPath, "check-offline.cjs"],
}),
/cannot invoke a command wrapper or shell/,
);
const blockedOfflineCommands: Array<{
args: string[];
expected: RegExp;
}> = [
{
args: ["PI_OFFLINE=1", "OTHER=value", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "sh", "-c", "touch src/bypass"],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "git", "clean", "-fd"],
expected: /Git host commands are not allowed/,
},
{
args: ["PI_OFFLINE=1", process.execPath, "-e", "process.exit(0)"],
expected: /cannot execute inline interpreter code/,
},
{
args: ["PI_OFFLINE=1", "env", "PI_OFFLINE=1", process.execPath],
expected: /cannot invoke a command wrapper or shell/,
},
{
args: ["PI_OFFLINE=1", "npm", "exec", "git"],
expected: /cannot invoke package-manager executable wrappers/,
},
];
for (const blocked of blockedOfflineCommands) {
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: blocked.args,
}),
blocked.expected,
);
}
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["mutate.cjs", "other/file.ts"],
}),
/outside ownership: other\/file.ts/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["reset", "--hard"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["-C", ".", "reset", "--hard"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "git",
args: ["-c", "alias.x=commit", "x", "-m", "bypass"],
}),
/Git host commands are not allowed/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "env",
args: ["git", "clean", "-fd"],
}),
/cannot invoke a command wrapper or shell/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["-e", 'require("node:child_process").execFileSync("git")'],
}),
/cannot execute inline interpreter code/,
);
await assert.rejects(
executor.run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: "sh",
args: ["-c", "touch src/shell-created"],
}),
/cannot invoke a command wrapper or shell/,
);
});
});
test("command runner reports bounded termination failure as exit 125", async () => {
await withTempDir(async (directory) => {
const runner = new ExecFileCommandRunner({
async terminateProcessTree(processId) {
if (process.platform !== "win32") {
process.kill(-processId, "SIGKILL");
} else {
process.kill(processId, "SIGKILL");
}
throw new Error("injected termination failure");
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /injected termination failure/);
assert.ok(Date.now() - started < 2_000);
});
});
test("command runner force-kills a child when the termination helper never settles", async () => {
await withTempDir(async (directory) => {
let childProcessId: number | undefined;
const runner = new ExecFileCommandRunner({
terminateProcessTree(processId) {
childProcessId = processId;
return new Promise<void>(() => {});
},
});
const started = Date.now();
const result = await runner.run(
directory,
process.execPath,
["-e", "setInterval(() => {}, 1000)"],
{ timeoutMs: 20 },
);
assert.equal(result.code, 125);
assert.match(result.stderr, /termination did not finish within its deadline/);
assert.ok(Date.now() - started < 2_000);
const terminatedProcessId = childProcessId;
assert.ok(terminatedProcessId);
assert.throws(
() => process.kill(terminatedProcessId, 0),
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
);
});
});
test("Git change detection excludes unchanged pre-existing dirty paths", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await writeFile(path.join(directory, "src", "existing.ts"), "base\n", "utf8");
await writeFile(path.join(directory, "src", "generated.ts"), "base\n", "utf8");
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
await writeFile(path.join(directory, "src", "existing.ts"), "dirty\n", "utf8");
const probe = new GitStateProbe();
const before = await probe.capture(directory);
await writeFile(path.join(directory, "src", "generated.ts"), "generated\n", "utf8");
const after = await probe.capture(directory);
assert.deepEqual(probe.changedSince(before, after), ["src/generated.ts"]);
});
});
test("post-command Git probe failures are workspace safety failures", async () => {
await withTempDir(async (directory) => {
await execFileAsync("git", ["init", "-q", directory]);
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
await mkdir(path.join(directory, "src"), { recursive: true });
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
await writeFile(
path.join(directory, "corrupt.cjs"),
'require("node:fs").renameSync(".git", ".git-broken")',
"utf8",
);
await execFileAsync("git", ["-C", directory, "add", "."]);
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
await assert.rejects(
new ApprovedHostCommandExecutor().run({
worktreeRoot: directory,
ownedPaths: ["src"],
executable: process.execPath,
args: ["corrupt.cjs"],
}),
(error: unknown) =>
error instanceof WorkspaceSafetyError &&
/Unable to verify Git safety after host command/.test(error.message),
);
});
});
test("workspace preparation validates Git state and builds the exact feature branch", async () => {
await withTempDir(async (repository) => {
const runner = new FakeRunner(prepareResponses(repository));
const manager = new GitWorkspaceManager(runner);
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "search-dashboard",
runId: "run-1",
});
assert.equal(plan.branch, "feature/search-dashboard");
assert.equal(
plan.worktreePath,
path.join(repository, ".worktrees", "workflow-search-dashboard-run-1"),
);
assert.equal(plan.baseHead, "abc123");
assert.equal(plan.branchExists, false);
const workspace = await manager.create(plan);
assert.equal(workspace.state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [
"worktree",
"add",
"-b",
"feature/search-dashboard",
plan.worktreePath,
"abc123",
]);
assert.ok(runner.calls.every((call) => call.executable === "git"));
assert.ok(
runner.calls.every(
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
),
);
});
});
test("real Git creation and reconciliation preserve branch and HEAD identity", async () => {
await withTempDir(async (repository) => {
const git = (args: string[]) =>
execFileAsync("git", args, { cwd: repository, encoding: "utf8" });
await git(["init", "-b", "main"]);
await git(["config", "user.email", "workflow@example.invalid"]);
await git(["config", "user.name", "Pi Workflow Test"]);
await writeFile(
path.join(repository, ".gitignore"),
".worktrees/\n",
"utf8",
);
await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8");
await git(["add", ".gitignore", "README.md"]);
await git(["commit", "-m", "initial fixture"]);
const manager = new GitWorkspaceManager();
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "real-feature",
runId: "run-1",
});
const workspace = await manager.create(plan);
assert.equal(workspace.state, "ready");
assert.equal(
(
await manager.reconcile({
...workspace,
state: "creating",
})
).state,
"ready",
);
const branch = await execFileAsync("git", ["branch", "--show-current"], {
cwd: plan.worktreePath,
encoding: "utf8",
});
assert.equal(branch.stdout.trim(), "feature/real-feature");
});
});
test("dirty sources require approval with bounded evidence while conflicts remain blocked", async () => {
await withTempDir(async (repository) => {
const dirty = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { dirty: true })),
);
await assert.rejects(
dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) => {
assert.ok(error instanceof WorkspaceValidationError);
assert.equal(error.code, "dirty-source");
assert.deepEqual(error.sourceChanges, [
{ path: "src/file.ts", status: " M" },
{ path: "src/new-file.ts", status: "??" },
]);
assert.equal(error.sourceChangesOmitted, 0);
return true;
},
);
const allowed = await dirty.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
});
assert.equal(allowed.baseHead, "abc123");
assert.equal(allowed.dirtySourceAllowed, true);
assert.equal(allowed.sourceChanges.length, 2);
const unmerged = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { unmerged: true })),
);
await assert.rejects(
unmerged.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
allowDirtySource: true,
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unmerged-source",
);
const unignored = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { ignored: false })),
);
await assert.rejects(
unignored.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unignored-root",
);
});
});
test("workspace preparation rejects a symlinked worktree root", async () => {
await withTempDir(async (repository) => {
const outside = await mkdtemp(path.join(tmpdir(), "workflow-outside-"));
try {
await symlink(outside, path.join(repository, ".worktrees"));
const manager = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository)),
);
await assert.rejects(
manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "unignored-root" &&
/symlink/.test(error.message),
);
} finally {
await rm(outside, { recursive: true, force: true });
}
});
});
test("workspace preparation reports branch collisions and existing paths", async () => {
await withTempDir(async (repository) => {
const manager = new GitWorkspaceManager(
new FakeRunner(prepareResponses(repository, { branchExists: true })),
);
const plan = await manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
});
assert.equal(plan.branchExists, true);
await mkdir(plan.worktreePath, { recursive: true });
await assert.rejects(
manager.prepare({
sourceRoot: repository,
configuredRoot: ".worktrees",
featureName: "feature",
runId: "run-1",
}),
(error) =>
error instanceof WorkspaceValidationError &&
error.code === "existing-worktree-path",
);
});
});
test("interrupted workspace creation reconciles only an exact path and branch", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const manager = new GitWorkspaceManager(
new FakeRunner((args) =>
args.join(" ") === "worktree list --porcelain"
? result(0, listed)
: result(1, "", "unexpected"),
),
);
const workspace = {
state: "creating" as const,
sourceRoot: repository,
root,
branch: "feature/feature",
baseHead: "abc123",
};
assert.equal((await manager.reconcile(workspace)).state, "ready");
assert.equal(
(
await manager.reconcile({
...workspace,
branch: "feature/other",
})
).state,
"failed",
);
assert.equal(
(await manager.reconcile({ ...workspace, baseHead: "different" })).state,
"failed",
);
assert.equal(
(await manager.reconcile(workspace, path.join(repository, "other")))
.state,
"failed",
);
const absent = new GitWorkspaceManager(new FakeRunner(() => result(0, "")));
assert.equal((await absent.reconcile(workspace)).state, "creating");
});
});
test("uncertain worktree-add results are reconciled before returning", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const plan = {
sourceRoot: repository,
worktreeRoot: path.dirname(root),
worktreePath: root,
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
const partial = new GitWorkspaceManager(
new FakeRunner((args) => {
if (args[0] === "show-ref") return result(1);
return args[0] === "worktree" && args[1] === "add"
? result(128, "", "interrupted")
: result(0, listed);
}),
);
assert.equal((await partial.create(plan)).state, "ready");
const absent = new GitWorkspaceManager(
new FakeRunner((args) => {
if (args[0] === "show-ref") return result(1);
return args[0] === "worktree" && args[1] === "add"
? result(128, "", "interrupted")
: result(0, "");
}),
);
const uncertain = await absent.create(plan);
assert.equal(uncertain.state, "creating");
assert.match(uncertain.error ?? "", /returned 128/);
});
});
test("retry adopts an exact orphan branch left by interrupted creation", async () => {
await withTempDir(async (repository) => {
const root = path.join(repository, ".worktrees", "run-1");
const runner = new FakeRunner((args) => {
if (args[0] === "show-ref") return result(0);
if (args[0] === "rev-parse") return result(0, "abc123\n");
if (args[0] === "worktree" && args[1] === "add") return result(0);
return result(1, "", "unexpected");
});
const manager = new GitWorkspaceManager(runner);
const plan = {
sourceRoot: repository,
worktreeRoot: path.dirname(root),
worktreePath: root,
branch: "feature/feature",
baseHead: "abc123",
branchExists: false,
sourceChanges: [],
sourceChangesOmitted: 0,
dirtySourceAllowed: false,
};
assert.equal((await manager.create(plan)).state, "ready");
assert.deepEqual(runner.calls.at(-1)?.args, [
"worktree",
"add",
root,
"feature/feature",
]);
const mismatch = new GitWorkspaceManager(
new FakeRunner((args) =>
args[0] === "show-ref" ? result(0) : result(0, "different\n"),
),
);
assert.equal((await mismatch.create(plan)).state, "failed");
});
});
File diff suppressed because it is too large Load Diff