add persisted pi workflow extension
This commit is contained in:
@@ -12,10 +12,10 @@
|
||||
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
type DefaultResourceLoader,
|
||||
defineTool,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type SettingsManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
type AgentSessionEventListener,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
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";
|
||||
@@ -68,7 +69,10 @@ export interface AgentOutcome {
|
||||
error?: string;
|
||||
aborted: boolean;
|
||||
usage: AgentUsage;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
childSessionId?: string;
|
||||
contextWindow?: number;
|
||||
transcript: TranscriptEntry[];
|
||||
}
|
||||
@@ -91,7 +95,15 @@ export interface RunAgentOptions {
|
||||
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. */
|
||||
@@ -430,18 +442,24 @@ export async function runAgent(
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentOutcome> {
|
||||
let structured: unknown;
|
||||
let customTools: ToolDefinition[] | undefined;
|
||||
let customTools: ToolDefinition<any, any, any>[];
|
||||
let session: AgentSession | undefined;
|
||||
let unsubscribeToolTimeout: (() => void) | undefined;
|
||||
try {
|
||||
customTools =
|
||||
options.schema !== undefined
|
||||
? [
|
||||
customTools = [
|
||||
...(options.customTools ?? []),
|
||||
...(options.schema === undefined
|
||||
? []
|
||||
: [
|
||||
makeStructuredOutputTool(options.schema, (value) => {
|
||||
structured = value;
|
||||
}),
|
||||
]
|
||||
: undefined;
|
||||
]),
|
||||
];
|
||||
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 } : {}),
|
||||
@@ -451,7 +469,8 @@ export async function runAgent(
|
||||
resourceLoader: options.loader,
|
||||
settingsManager: options.settingsManager,
|
||||
sessionManager: SessionManager.inMemory(options.cwd),
|
||||
...(customTools ? { customTools } : {}),
|
||||
...(customTools.length > 0 ? { customTools } : {}),
|
||||
...(options.tools ? { tools: options.tools } : {}),
|
||||
...childToolPolicy(),
|
||||
}));
|
||||
await bindChildSessionExtensions(session);
|
||||
@@ -468,15 +487,30 @@ export async function runAgent(
|
||||
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;
|
||||
@@ -487,6 +521,7 @@ export async function runAgent(
|
||||
usage = computeUsage(messages);
|
||||
|
||||
const sessionModel = childSession.model;
|
||||
provider = sessionModel?.provider ?? provider;
|
||||
modelId = sessionModel?.id ?? modelId;
|
||||
contextWindow = sessionModel?.contextWindow ?? contextWindow;
|
||||
const context = childSession.getContextUsage();
|
||||
@@ -515,6 +550,7 @@ export async function runAgent(
|
||||
!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)
|
||||
@@ -555,9 +591,14 @@ export async function runAgent(
|
||||
|
||||
let aborted = false;
|
||||
let abortPromise: Promise<void> | undefined;
|
||||
let resolveCancellation = () => {};
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
abortPromise ??= childSession.abort().catch(() => {});
|
||||
resolveCancellation();
|
||||
abortPromise ??= waitForChildOperation(childSession.abort());
|
||||
};
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) onAbort();
|
||||
@@ -573,9 +614,12 @@ export async function runAgent(
|
||||
model: modelId,
|
||||
});
|
||||
markFirstResponse = watchdog.markResponse;
|
||||
await watchdog.waitFor(
|
||||
childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
|
||||
);
|
||||
await Promise.race([
|
||||
watchdog.waitFor(
|
||||
childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
|
||||
),
|
||||
cancellation,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = errorMessage ?? errorText(error);
|
||||
@@ -602,7 +646,10 @@ export async function runAgent(
|
||||
error: "Agent was aborted",
|
||||
aborted: true,
|
||||
usage,
|
||||
provider,
|
||||
model: modelId,
|
||||
thinkingLevel,
|
||||
childSessionId,
|
||||
contextWindow,
|
||||
transcript,
|
||||
};
|
||||
@@ -617,7 +664,10 @@ export async function runAgent(
|
||||
error: errorMessage ?? "Agent failed",
|
||||
aborted: false,
|
||||
usage,
|
||||
provider,
|
||||
model: modelId,
|
||||
thinkingLevel,
|
||||
childSessionId,
|
||||
contextWindow,
|
||||
transcript,
|
||||
};
|
||||
@@ -631,7 +681,10 @@ export async function runAgent(
|
||||
"Agent finished without calling structured_output; no structured result matching the schema was produced.",
|
||||
aborted: false,
|
||||
usage,
|
||||
provider,
|
||||
model: modelId,
|
||||
thinkingLevel,
|
||||
childSessionId,
|
||||
contextWindow,
|
||||
transcript,
|
||||
};
|
||||
@@ -643,7 +696,10 @@ export async function runAgent(
|
||||
structured,
|
||||
aborted: false,
|
||||
usage,
|
||||
provider,
|
||||
model: modelId,
|
||||
thinkingLevel,
|
||||
childSessionId,
|
||||
contextWindow,
|
||||
transcript,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
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. The plan is complete when 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,178 @@
|
||||
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(), "pi-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);
|
||||
});
|
||||
});
|
||||
|
||||
test("a lowered ceiling clamps an inherited default", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "pi-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, "pi-workflow.json"), {
|
||||
concurrency: { default: 6, ceiling: 16 },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "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, "pi-workflow.json"), {
|
||||
concurrency: { default: 6, ceiling: 16 },
|
||||
maxAssignmentsPerRun: 64,
|
||||
analysisTools: ["read", "grep", "find"],
|
||||
worktrees: { root: ".worktrees/global" },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "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", "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("configuration cannot raise ceilings or activate unaudited tools", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
concurrency: { ceiling: 33 },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/between 1 and 32/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "pi-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, "pi-workflow.json"), {
|
||||
concurrency: { default: 4, ceiling: 8 },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "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, "pi-workflow.json"), {
|
||||
maxAssignmentsPerRun: 1.5,
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/must be an integer/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
worktrees: { root: "../outside" },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/must stay inside the repository/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
concurrency: { default: 8, ceiling: 8 },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
invocationConcurrency: 9,
|
||||
}),
|
||||
/between 1 and 8/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
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,
|
||||
type WorkflowConfig,
|
||||
} from "./domain.ts";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set([
|
||||
"concurrency",
|
||||
"maxAssignmentsPerRun",
|
||||
"maxReviewCycles",
|
||||
"analysisTools",
|
||||
"worktrees",
|
||||
]);
|
||||
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
|
||||
const WORKTREE_KEYS = new Set(["root"]);
|
||||
|
||||
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],
|
||||
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.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, "pi-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", "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,509 @@
|
||||
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(), "pi-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 api = {
|
||||
registerCommand(_name: string, options: { handler: CommandHandler }) {
|
||||
command = options.handler;
|
||||
},
|
||||
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,
|
||||
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",
|
||||
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"];
|
||||
sent?: unknown[];
|
||||
}) {
|
||||
const notifications: string[] = [];
|
||||
const context = {
|
||||
cwd: "/repo",
|
||||
sessionManager: {
|
||||
getSessionId: () => options.sessionId,
|
||||
getSessionFile: () => options.sessionFile,
|
||||
},
|
||||
ui: {
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
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 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 ?? "", /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("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 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,399 @@
|
||||
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 {
|
||||
readVerifiedPlanArtifact,
|
||||
type WorkflowStore,
|
||||
} from "./store.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 pi-workflow run ${view.runId}.`,
|
||||
`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: "pi-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 ${runId} 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 pi-workflow run ${view.runId}.`,
|
||||
`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;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinue(
|
||||
runtime: ContextTransitionRuntime,
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
) {
|
||||
const match = args.trim().match(/^continue\s+(\S+)$/);
|
||||
if (!match) {
|
||||
ctx.ui.notify("Usage: /workflow continue <run-id>", "error");
|
||||
return;
|
||||
}
|
||||
await ctx.waitForIdle();
|
||||
const runId = match[1];
|
||||
if (!runId) throw new Error("Workflow Run ID is missing");
|
||||
const { view } = await inspect(runtime, ctx, runId);
|
||||
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 ${runId} has no pending context continuation`);
|
||||
}
|
||||
|
||||
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: "Inspect and continue persisted pi-workflow runs",
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
if (/^continue\s+\S+$/.test(args.trim())) {
|
||||
await handleContinue(runtime, args, ctx);
|
||||
return;
|
||||
}
|
||||
if (openDashboard) {
|
||||
const result = await openDashboard(args.trim(), ctx);
|
||||
if (result.continueRunId) {
|
||||
await handleContinue(
|
||||
runtime,
|
||||
`continue ${result.continueRunId}`,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await handleContinue(runtime, args, 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,384 @@
|
||||
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 { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import {
|
||||
FocusedWorkflowDashboard,
|
||||
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/pi-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,
|
||||
};
|
||||
}
|
||||
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
|
||||
function view(workflow: WorkflowRun): WorkflowView {
|
||||
return {
|
||||
runId: workflow.runId,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
test("focused dashboard renders compact and narrow cards within width", () => {
|
||||
let selected: string | null | undefined;
|
||||
const dashboard = new FocusedWorkflowDashboard(
|
||||
[run()],
|
||||
theme,
|
||||
(value) => {
|
||||
selected = value;
|
||||
},
|
||||
);
|
||||
dashboard.handleInput("\r");
|
||||
const wide = dashboard.render(100);
|
||||
assert.ok(wide.some((line) => line.includes("Progress")));
|
||||
assert.ok(wide.some((line) => line.includes("Worktree")));
|
||||
assert.ok(wide.every((line) => visibleWidth(line) <= 100));
|
||||
const narrow = dashboard.render(48);
|
||||
assert.equal(narrow.some((line) => line.includes("Worktree")), false);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 48));
|
||||
dashboard.handleInput("\r");
|
||||
assert.equal(selected, "run-1");
|
||||
});
|
||||
|
||||
test("dashboard windows long-lived session run lists", () => {
|
||||
const runs = Array.from({ length: 30 }, (_, index) =>
|
||||
run({ runId: `run-${index}`, featureName: `feature-${index}` }),
|
||||
);
|
||||
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
|
||||
const lines = dashboard.render(100);
|
||||
assert.ok(lines.length <= 16);
|
||||
assert.ok(lines.some((line) => line.includes("of 30 runs")));
|
||||
});
|
||||
|
||||
test("dashboard records explicit Plan Approval through the engine", async () => {
|
||||
const workflow = run({
|
||||
planArtifact: {
|
||||
path: "/state/artifacts/plan.md",
|
||||
revision: 3,
|
||||
digest: "digest",
|
||||
},
|
||||
});
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const selections = [
|
||||
"Record Gate Decision",
|
||||
"Approve plan · UI impact minor",
|
||||
];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: {
|
||||
getSessionId: () => "session-1",
|
||||
},
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => selections.shift(),
|
||||
confirm: async () => true,
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
const pi = {
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return view(workflow);
|
||||
},
|
||||
}) as never,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const decision = commands[0];
|
||||
assert.equal(decision?.action, "decide");
|
||||
assert.deepEqual(
|
||||
decision?.action === "decide" ? decision.decision : undefined,
|
||||
{
|
||||
kind: "approve-plan",
|
||||
approved: true,
|
||||
acceptedUiImpact: "minor",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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" },
|
||||
});
|
||||
let actions: string[] = [];
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-2" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async (_title: string, choices: string[]) => {
|
||||
actions = choices;
|
||||
return undefined;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.equal(actions.includes("Record Gate Decision"), false);
|
||||
assert.equal(actions.includes("Continue context transition"), false);
|
||||
assert.equal(actions.includes("Open plan"), true);
|
||||
});
|
||||
|
||||
test("dashboard opens retained report content on demand", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "pi-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,
|
||||
},
|
||||
});
|
||||
let opened = "";
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => "Open report",
|
||||
editor: async (_title: string, contents: string) => {
|
||||
opened = contents;
|
||||
return contents;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.match(opened, /Completed report[\s\S]*Verified/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard verifies and opens Assignment transcripts on demand", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "pi-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 selections = [
|
||||
"Browse Assignment transcripts",
|
||||
"1. review/independent-reviewer · succeeded",
|
||||
];
|
||||
let opened = "";
|
||||
const ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => selections.shift(),
|
||||
editor: async (_title: string, contents: string) => {
|
||||
opened = contents;
|
||||
return contents;
|
||||
},
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.match(opened, /assistant.*verified/);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard exposes continuation only for pending handoff intents", 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 ctx = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => "run-1",
|
||||
select: async () => "Continue context transition",
|
||||
notify: () => {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
const result = await openWorkflowDashboard(
|
||||
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(result, { continueRunId: "run-1" });
|
||||
});
|
||||
@@ -0,0 +1,557 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
type Component,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { isValidContextContinuation } from "./continuation.ts";
|
||||
import type {
|
||||
GateDecision,
|
||||
PrototypeCandidate,
|
||||
WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import {
|
||||
executeHostDecision,
|
||||
type ContextTransitionRuntime,
|
||||
} from "./context-transition.ts";
|
||||
import {
|
||||
readVerifiedPlanArtifact,
|
||||
readVerifiedReportArtifact,
|
||||
readVerifiedTranscriptArtifact,
|
||||
} from "./store.ts";
|
||||
|
||||
interface DashboardTheme {
|
||||
fg(
|
||||
color:
|
||||
| "accent"
|
||||
| "dim"
|
||||
| "error"
|
||||
| "muted"
|
||||
| "success"
|
||||
| "text"
|
||||
| "warning",
|
||||
text: string,
|
||||
): string;
|
||||
bold(text: string): string;
|
||||
}
|
||||
|
||||
function statusColor(status: WorkflowRun["status"]) {
|
||||
if (status === "completed") return "success" as const;
|
||||
if (status === "failed" || status === "aborted") return "error" as const;
|
||||
if (status === "waiting") return "warning" as const;
|
||||
return "accent" 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 stageLine(run: WorkflowRun, theme: DashboardTheme) {
|
||||
return run.stages
|
||||
.filter((stage) =>
|
||||
["discover", "design", "prototype", "implement", "verify", "review"].includes(
|
||||
stage.kind,
|
||||
),
|
||||
)
|
||||
.map((stage) => {
|
||||
const marker =
|
||||
stage.status === "completed"
|
||||
? theme.fg("success", "✓")
|
||||
: stage.status === "failed"
|
||||
? theme.fg("error", "×")
|
||||
: stage.status === "running" || stage.status === "waiting"
|
||||
? theme.fg("accent", "●")
|
||||
: theme.fg("dim", "·");
|
||||
return `${marker} ${stage.kind}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export class FocusedWorkflowDashboard implements Component {
|
||||
private readonly runs: WorkflowRun[];
|
||||
private readonly theme: DashboardTheme;
|
||||
private readonly done: (result: string | null) => void;
|
||||
private selected = 0;
|
||||
private detail = false;
|
||||
|
||||
constructor(
|
||||
runs: WorkflowRun[],
|
||||
theme: DashboardTheme,
|
||||
done: (result: string | null) => void,
|
||||
initialRunId?: string,
|
||||
) {
|
||||
this.runs = runs;
|
||||
this.theme = theme;
|
||||
this.done = done;
|
||||
const initial = initialRunId
|
||||
? runs.findIndex((run) => run.runId === initialRunId)
|
||||
: -1;
|
||||
if (initial >= 0) {
|
||||
this.selected = initial;
|
||||
this.detail = true;
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.detail) this.detail = false;
|
||||
else this.done(null);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up) && this.selected > 0) {
|
||||
this.selected--;
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down) && this.selected < this.runs.length - 1) {
|
||||
this.selected++;
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const run = this.runs[this.selected];
|
||||
if (!run) return;
|
||||
if (this.detail) this.done(run.runId);
|
||||
else this.detail = true;
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
const lines = [this.theme.fg("accent", this.theme.bold("pi-workflow")), ""];
|
||||
if (this.runs.length === 0) {
|
||||
lines.push(this.theme.fg("muted", "No Workflow Runs are attached to this session."));
|
||||
lines.push("", this.theme.fg("dim", "esc close"));
|
||||
return lines.map((line) => truncateToWidth(line, width));
|
||||
}
|
||||
const windowSize = 10;
|
||||
const windowStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selected - Math.floor(windowSize / 2),
|
||||
Math.max(0, this.runs.length - windowSize),
|
||||
),
|
||||
);
|
||||
const visibleRuns = this.runs.slice(windowStart, windowStart + windowSize);
|
||||
for (const [visibleIndex, run] of visibleRuns.entries()) {
|
||||
const index = windowStart + visibleIndex;
|
||||
const prefix = index === this.selected ? "> " : " ";
|
||||
const name = run.featureName ?? run.request.slice(0, 24);
|
||||
const status = this.theme.fg(
|
||||
statusColor(run.status),
|
||||
run.status.toUpperCase().padEnd(9),
|
||||
);
|
||||
lines.push(
|
||||
`${prefix}${name.padEnd(24)} ${status} ${String(activeStage(run)).padEnd(20)} r${run.revision}`,
|
||||
);
|
||||
}
|
||||
if (this.runs.length > windowSize) {
|
||||
lines.push(
|
||||
this.theme.fg(
|
||||
"dim",
|
||||
`${windowStart + 1}-${windowStart + visibleRuns.length} of ${this.runs.length} runs`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.detail) {
|
||||
const run = this.runs[this.selected]!;
|
||||
const gate = selectedGate(run);
|
||||
lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width))));
|
||||
lines.push(this.theme.bold(run.featureName ?? run.request));
|
||||
if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch));
|
||||
lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`);
|
||||
if (gate) {
|
||||
lines.push(
|
||||
"",
|
||||
`${this.theme.bold("Gate")} ${gate.kind} · revision ${gate.revision}`,
|
||||
);
|
||||
}
|
||||
if (run.planArtifact) {
|
||||
lines.push(`${this.theme.bold("Plan")} ${run.planArtifact.path}`);
|
||||
}
|
||||
if (run.reportArtifact) {
|
||||
lines.push(`${this.theme.bold("Report")} ${run.reportArtifact.path}`);
|
||||
}
|
||||
if (width >= 80 && run.workspace.root) {
|
||||
lines.push(`${this.theme.bold("Worktree")} ${run.workspace.root}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
this.theme.fg("dim", "enter actions esc runs"),
|
||||
);
|
||||
} else {
|
||||
lines.push("", this.theme.fg("dim", "↑↓ run enter details esc close"));
|
||||
}
|
||||
return lines.map((line) => truncateToWidth(line, width));
|
||||
}
|
||||
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
function runSummary(run: WorkflowRun) {
|
||||
const gate = selectedGate(run);
|
||||
return [
|
||||
`${run.runId} · ${run.status} · revision ${run.revision}`,
|
||||
`Stage: ${activeStage(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 gate = selectedGate(run);
|
||||
const payload = gate?.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 ctx.ui.input(prompt, "Required answer");
|
||||
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;
|
||||
const action = await ctx.ui.select("Plan Approval", [
|
||||
"Inspect plan",
|
||||
`Approve plan · UI impact ${run.uiImpact}`,
|
||||
"Request revision",
|
||||
]);
|
||||
if (action === "Inspect plan") {
|
||||
const plan = await readVerifiedPlanArtifact(run.planArtifact);
|
||||
await ctx.ui.editor("Approved plan (read-only copy)", plan.slice(0, 32 * 1024));
|
||||
return undefined;
|
||||
}
|
||||
if (action === "Request revision") {
|
||||
const feedback = await ctx.ui.editor("Plan revision feedback", "");
|
||||
return feedback?.trim()
|
||||
? { kind: "revise-plan" as const, feedback: feedback.trim() }
|
||||
: undefined;
|
||||
}
|
||||
if (action?.startsWith("Approve plan")) {
|
||||
const approved = await ctx.ui.confirm(
|
||||
"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;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const candidates = run.prototypeCandidates.slice(-3);
|
||||
const items = candidates.map(
|
||||
(candidate, index) =>
|
||||
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
|
||||
);
|
||||
const action = await ctx.ui.select("Prototype Acceptance", [
|
||||
...items,
|
||||
"Request refinement",
|
||||
"Abort run",
|
||||
]);
|
||||
if (action === "Request refinement") {
|
||||
const feedback = await ctx.ui.editor("Prototype refinement feedback", "");
|
||||
return feedback?.trim()
|
||||
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
|
||||
: undefined;
|
||||
}
|
||||
if (action === "Abort run") return "abort" as const;
|
||||
const index = items.indexOf(action ?? "");
|
||||
const candidate: PrototypeCandidate | undefined = candidates[index];
|
||||
if (!candidate) return undefined;
|
||||
ctx.ui.notify(
|
||||
[
|
||||
candidate.launchInstructions,
|
||||
`Route: ${candidate.route}`,
|
||||
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
|
||||
].join("\n"),
|
||||
"info",
|
||||
);
|
||||
const browserReviewed = await ctx.ui.confirm(
|
||||
"Accept prototype",
|
||||
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
|
||||
);
|
||||
if (!browserReviewed) return undefined;
|
||||
const feedback = await ctx.ui.input("Acceptance feedback", "Optional");
|
||||
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 ctx.ui.select("Implementation Context", [
|
||||
"Fresh plan-seeded session",
|
||||
"Compact current session",
|
||||
"Keep current context unchanged",
|
||||
]);
|
||||
if (strategy === "Fresh plan-seeded session") {
|
||||
return { kind: "select-implementation-context" as const, strategy: "fresh-session" as const };
|
||||
}
|
||||
if (strategy === "Compact current session") {
|
||||
return { kind: "select-implementation-context" as const, strategy: "smart-compact" as const };
|
||||
}
|
||||
if (strategy === "Keep current context unchanged") {
|
||||
return { kind: "select-implementation-context" as const, strategy: "current-session" as const };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function recoveryDecision(ctx: ExtensionContext) {
|
||||
const resolution = await ctx.ui.select("Recovery", ["Retry", "Inspect", "Abort"]);
|
||||
if (!resolution) return undefined;
|
||||
return {
|
||||
kind: "recover" as const,
|
||||
resolution: resolution.toLowerCase() as "retry" | "inspect" | "abort",
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
) {
|
||||
const candidates = run.prototypeCandidates;
|
||||
const labels = candidates.map(
|
||||
(candidate, index) =>
|
||||
`${index + 1}. ${candidate.structuralRationale} · ${candidate.route}`,
|
||||
);
|
||||
const selected = await ctx.ui.select("Prototype evidence", labels);
|
||||
const candidate = candidates[labels.indexOf(selected ?? "")];
|
||||
if (!candidate) return;
|
||||
ctx.ui.notify(
|
||||
`${candidate.launchInstructions}\nRoute: ${candidate.route}`,
|
||||
"info",
|
||||
);
|
||||
const artifacts = [candidate.artifactPath, ...candidate.retainedArtifacts];
|
||||
const artifact = await ctx.ui.select("Retained artifact", artifacts);
|
||||
if (!artifact) return;
|
||||
if (artifact.endsWith(".json")) {
|
||||
const contents = await readFile(artifact, "utf8");
|
||||
await ctx.ui.editor("Retained prototype evidence", contents.slice(0, 32 * 1024));
|
||||
} else {
|
||||
ctx.ui.notify(artifact, "info");
|
||||
}
|
||||
}
|
||||
|
||||
async function browseAssignmentTranscripts(
|
||||
ctx: ExtensionContext,
|
||||
run: WorkflowRun,
|
||||
) {
|
||||
const assignments = run.assignments.filter(
|
||||
(assignment) => assignment.transcriptArtifact,
|
||||
);
|
||||
const labels = assignments.map(
|
||||
(assignment, index) =>
|
||||
`${index + 1}. ${assignment.role}/${assignment.profileName} · ${assignment.status}`,
|
||||
);
|
||||
const selected = await ctx.ui.select("Assignment transcripts", labels);
|
||||
const assignment = assignments[labels.indexOf(selected ?? "")];
|
||||
if (!assignment?.transcriptArtifact) return;
|
||||
const transcript = await readVerifiedTranscriptArtifact(
|
||||
assignment.transcriptArtifact,
|
||||
);
|
||||
await ctx.ui.editor(
|
||||
`${assignment.role} transcript`,
|
||||
transcript.slice(0, 32 * 1024),
|
||||
);
|
||||
}
|
||||
|
||||
async function actionSheet(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionCommandContext,
|
||||
run: WorkflowRun,
|
||||
) {
|
||||
const actions = [
|
||||
"Inspect run",
|
||||
...(selectedGate(run) &&
|
||||
isActiveParent(run, ctx.sessionManager.getSessionId())
|
||||
? ["Record Gate Decision"]
|
||||
: []),
|
||||
...(validContinuation(run, ctx.sessionManager.getSessionId())
|
||||
? ["Continue context transition"]
|
||||
: []),
|
||||
...(run.planArtifact ? ["Open plan"] : []),
|
||||
...(run.reportArtifact ? ["Open report"] : []),
|
||||
...(run.prototypeCandidates.length > 0 ? ["Browse prototype evidence"] : []),
|
||||
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
|
||||
? ["Browse Assignment transcripts"]
|
||||
: []),
|
||||
...(run.workspace.root ? ["Show worktree path"] : []),
|
||||
];
|
||||
const action = await ctx.ui.select(run.featureName ?? run.runId, actions);
|
||||
if (action === "Inspect run") ctx.ui.notify(runSummary(run), "info");
|
||||
else if (action === "Open plan") {
|
||||
const plan = await readVerifiedPlanArtifact(run.planArtifact!);
|
||||
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024));
|
||||
} else if (action === "Open report") {
|
||||
const report = await readVerifiedReportArtifact(run.reportArtifact!);
|
||||
await ctx.ui.editor("Workflow report", report.slice(0, 32 * 1024));
|
||||
} else if (action === "Browse prototype evidence") {
|
||||
await browsePrototypeArtifacts(ctx, run);
|
||||
} else if (action === "Browse Assignment transcripts") {
|
||||
await browseAssignmentTranscripts(ctx, run);
|
||||
} else if (action === "Show worktree path") {
|
||||
ctx.ui.notify(run.workspace.root!, "info");
|
||||
}
|
||||
else if (action === "Continue context transition") {
|
||||
return { continueRunId: run.runId };
|
||||
} else if (action === "Record Gate Decision") {
|
||||
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 ${run.runId} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
|
||||
"info",
|
||||
);
|
||||
} else {
|
||||
ctx.ui.notify(`Workflow ${run.runId}: ${result.status}`, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
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) => right.revision - left.revision);
|
||||
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(runSummary).join("\n\n")}${
|
||||
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
|
||||
}`
|
||||
: "No Workflow Runs.",
|
||||
"info",
|
||||
);
|
||||
return {};
|
||||
}
|
||||
const selected = await ctx.ui.custom<string | null>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
const dashboard = new FocusedWorkflowDashboard(
|
||||
runs,
|
||||
theme as DashboardTheme,
|
||||
done,
|
||||
initialRunId,
|
||||
);
|
||||
return {
|
||||
render: (width) => dashboard.render(width),
|
||||
invalidate: () => dashboard.invalidate(),
|
||||
handleInput: (data) => {
|
||||
dashboard.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
if (!selected) return {};
|
||||
const run = runs.find((candidate) => candidate.runId === selected);
|
||||
return run ? actionSheet(pi, runtime, ctx, run) : {};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
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";
|
||||
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 WorkflowWorkspace {
|
||||
state: "not-required" | "creating" | "ready" | "failed";
|
||||
sourceRoot: string;
|
||||
root?: string;
|
||||
branch?: string;
|
||||
baseHead?: string;
|
||||
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 WorkflowConfig {
|
||||
concurrency: {
|
||||
default: number;
|
||||
ceiling: number;
|
||||
};
|
||||
maxAssignmentsPerRun: number;
|
||||
maxReviewCycles: number;
|
||||
analysisTools: string[];
|
||||
worktrees: {
|
||||
root: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = {
|
||||
concurrency: {
|
||||
default: 8,
|
||||
ceiling: MAX_CONCURRENCY,
|
||||
},
|
||||
maxAssignmentsPerRun: 128,
|
||||
maxReviewCycles: 3,
|
||||
analysisTools: [...ANALYSIS_TOOL_ALLOWLIST],
|
||||
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,862 @@
|
||||
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(), "pi-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, "pi-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 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,
|
||||
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,
|
||||
};
|
||||
},
|
||||
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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 "../shared/child-session.ts";
|
||||
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: "pi-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);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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 { registerPiWorkflowTool } from "./tool.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(), "pi-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(),
|
||||
});
|
||||
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(
|
||||
`pi-workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function piWorkflowExtension(pi: ExtensionAPI) {
|
||||
let runtime: ContextTransitionRuntime;
|
||||
runtime = createWorkflowRuntime({
|
||||
afterRunPersist: async (ctx) => {
|
||||
await reconcileWorkflowDeliveries(pi, runtime, ctx);
|
||||
},
|
||||
});
|
||||
registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
|
||||
openWorkflowDashboard(pi, runtime, ctx, args || undefined),
|
||||
);
|
||||
registerPiWorkflowTool(pi, runtime);
|
||||
registerWorkflowRecovery(pi, runtime);
|
||||
registerWorkflowNotifications(pi, runtime);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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(), "pi-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);
|
||||
assert.equal(
|
||||
(harness.sent[0]?.message as Record<string, unknown>).customType,
|
||||
workflowNotificationType,
|
||||
);
|
||||
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),
|
||||
/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,207 @@
|
||||
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";
|
||||
|
||||
const NOTIFICATION_TYPE = "pi-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) {
|
||||
if (key.startsWith("gate:")) {
|
||||
const gate = [...run.gates]
|
||||
.reverse()
|
||||
.find((candidate) => key.includes(`:${candidate.id}:`));
|
||||
return gate
|
||||
? `pi-workflow ${run.runId} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
|
||||
: `pi-workflow ${run.runId} is waiting for a Gate Decision.`;
|
||||
}
|
||||
const report = run.reportArtifact?.path
|
||||
? ` Report: ${run.reportArtifact.path}`
|
||||
: "";
|
||||
return `pi-workflow ${run.runId} 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(
|
||||
`pi-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 === "pi_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,494 @@
|
||||
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(), "pi-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`;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
test("bundled profiles form a complete deterministic catalog", async () => {
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: process.cwd(),
|
||||
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("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"],
|
||||
): AgentProfile {
|
||||
return {
|
||||
name,
|
||||
description: name,
|
||||
roles: ["discover"],
|
||||
selection,
|
||||
instructions: "Discover.",
|
||||
source: "bundled",
|
||||
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("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"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
PROFILE_ROLES,
|
||||
REASONING_EFFORTS,
|
||||
type AgentProfile,
|
||||
type AssignmentStageKind,
|
||||
type EligibilityResult,
|
||||
type ProfileCatalog,
|
||||
type ProfileDiagnostic,
|
||||
type ProfileRole,
|
||||
type ProfileSelection,
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface LoadAgentProfilesOptions {
|
||||
cwd: string;
|
||||
projectTrusted: boolean;
|
||||
bundledRoot?: string;
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
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 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,
|
||||
): 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 { profile, warnings } = buildProfile(
|
||||
contents,
|
||||
sourcePath,
|
||||
resolvedPath,
|
||||
source,
|
||||
);
|
||||
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 projectRoot = options.projectTrusted
|
||||
? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd)))
|
||||
: undefined;
|
||||
if (projectRoot) await assertProjectProfileAncestry(projectRoot);
|
||||
const project = projectRoot
|
||||
? await loadSource(projectRoot, "project", false)
|
||||
: { profiles: [], diagnostics: [] };
|
||||
|
||||
const byName = new Map(
|
||||
bundled.profiles.map((profile) => [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, ...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,
|
||||
) {
|
||||
return profiles
|
||||
.map((profile) => evaluateProfileEligibility(profile, facts, role))
|
||||
.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,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: "pi-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: "pi-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,512 @@
|
||||
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(), "pi-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, /\[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;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,924 @@
|
||||
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 type {
|
||||
PlanArtifact,
|
||||
WorkflowReportArtifact,
|
||||
WorkflowRun,
|
||||
} from "./domain.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;
|
||||
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 = [
|
||||
`# pi-workflow ${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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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,
|
||||
registerPiWorkflowTool,
|
||||
workflowCommandFromTool,
|
||||
} from "./tool.ts";
|
||||
|
||||
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
|
||||
return {
|
||||
runId: "run-1",
|
||||
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.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("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;
|
||||
registerPiWorkflowTool(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("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;
|
||||
registerPiWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return workflowView();
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.equal(definition?.name, "pi_workflow");
|
||||
assert.ok(definition);
|
||||
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"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
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";
|
||||
|
||||
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 PiWorkflowParameters = 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()),
|
||||
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 PiWorkflowInput = Static<typeof PiWorkflowParameters>;
|
||||
|
||||
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: PiWorkflowInput,
|
||||
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 }
|
||||
: {}),
|
||||
...(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: PiWorkflowInput["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: PiWorkflowInput["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) {
|
||||
const lines = [
|
||||
`Workflow ${details.runId}: ${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: PiWorkflowInput,
|
||||
toolCallId: string,
|
||||
) {
|
||||
let runId = input.runId;
|
||||
if (!runId && input.action === "start" && input.idempotencyKey) {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
const start = await runtime.store.getStart(input.idempotencyKey);
|
||||
if (start) {
|
||||
runId = start.runId;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
export function registerPiWorkflowTool(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
) {
|
||||
pi.registerTool({
|
||||
name: "pi_workflow",
|
||||
label: "Pi Workflow",
|
||||
description:
|
||||
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. 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 pi_workflow when the user asks to plan or build a non-trivial feature through the managed workflow.",
|
||||
"Never use pi_workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.",
|
||||
"Use pi_workflow inspect before every state-changing call and pass its current revision and Gate ID.",
|
||||
],
|
||||
parameters: PiWorkflowParameters,
|
||||
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 operation =
|
||||
command.action === "decide"
|
||||
? executeHostDecision(pi, engine, command)
|
||||
: engine.execute(command);
|
||||
let rejectCancellation = (_error: unknown) => {};
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
rejectCancellation = reject;
|
||||
});
|
||||
let cancellationWork: Promise<void> | undefined;
|
||||
const cancellationError = () =>
|
||||
executionSignal.reason instanceof Error
|
||||
? executionSignal.reason
|
||||
: new Error("pi_workflow tool call was cancelled");
|
||||
const onAbort = () => {
|
||||
cancellationWork = cancelToolWorkflow(
|
||||
runtime,
|
||||
engine,
|
||||
params,
|
||||
toolCallId,
|
||||
);
|
||||
void cancellationWork.finally(() => {
|
||||
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();
|
||||
}
|
||||
} finally {
|
||||
executionSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
const details = compactWorkflowDetails(
|
||||
params.action,
|
||||
view,
|
||||
ctx.sessionManager.getSessionId(),
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: resultText(details) }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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("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");
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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/",
|
||||
];
|
||||
|
||||
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("\\", "/")}`;
|
||||
if (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,671 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
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,
|
||||
FilesystemPrototypeValidator,
|
||||
GitStateProbe,
|
||||
GitWorkspaceManager,
|
||||
MutationScope,
|
||||
WorkspaceSafetyError,
|
||||
WorkspaceValidationError,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
} 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(), "pi-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[] }> =
|
||||
[];
|
||||
private readonly respond: (args: string[]) => CommandResult;
|
||||
|
||||
constructor(respond: (args: string[]) => CommandResult) {
|
||||
this.respond = respond;
|
||||
}
|
||||
|
||||
async run(cwd: string, executable: string, args: string[]) {
|
||||
this.calls.push({ cwd, executable, args: [...args] });
|
||||
return this.respond(args);
|
||||
}
|
||||
}
|
||||
|
||||
function result(code = 0, stdout = "", stderr = ""): CommandResult {
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
function prepareResponses(
|
||||
repository: string,
|
||||
options: { dirty?: 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 --untracked-files=normal") {
|
||||
return result(0, options.dirty ? " M src/file.ts\n" : "");
|
||||
}
|
||||
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 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"]);
|
||||
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("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", "pi-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"));
|
||||
});
|
||||
});
|
||||
|
||||
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", "pi-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 and unignored worktree roots fail before creation", 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) =>
|
||||
error instanceof WorkspaceValidationError &&
|
||||
error.code === "dirty-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(), "pi-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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
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
@@ -20,6 +20,7 @@ import {
|
||||
createChildResources,
|
||||
resolveStandaloneChildProjectTrust,
|
||||
shutdownAndDisposeChildSession,
|
||||
waitForChildOperation,
|
||||
type DisposableChildSession,
|
||||
} from "./child-session.ts";
|
||||
|
||||
@@ -105,6 +106,7 @@ test("child denylist keeps extension and workflow structured tools available", a
|
||||
"subagent_check",
|
||||
"subagent_list",
|
||||
"workflow",
|
||||
"pi_workflow",
|
||||
"ask_user",
|
||||
],
|
||||
);
|
||||
@@ -253,6 +255,12 @@ test("shutdown helper balances hooks and disposal despite errors", async () => {
|
||||
assert.equal(disposals, 1);
|
||||
});
|
||||
|
||||
test("child operations are bounded independently of teardown", async () => {
|
||||
const startedAt = Date.now();
|
||||
await waitForChildOperation(new Promise(() => {}), 5);
|
||||
assert.ok(Date.now() - startedAt < 100);
|
||||
});
|
||||
|
||||
test("shutdown helper bounds a stuck hook before disposal", async () => {
|
||||
let disposals = 0;
|
||||
const session: DisposableChildSession = {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const CHILD_EXCLUDED_TOOL_NAMES = [
|
||||
"subagent_check",
|
||||
"subagent_list",
|
||||
"workflow",
|
||||
"pi_workflow",
|
||||
"ask_user",
|
||||
] as const;
|
||||
|
||||
@@ -92,7 +93,10 @@ export interface DisposableChildSession {
|
||||
|
||||
const childShutdowns = new WeakMap<object, Promise<void>>();
|
||||
|
||||
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
|
||||
export function waitForChildOperation(
|
||||
operation: Promise<unknown>,
|
||||
timeoutMs = CHILD_SHUTDOWN_TIMEOUT_MS,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
@@ -124,7 +128,7 @@ export function shutdownAndDisposeChildSession(
|
||||
const shutdown = (async () => {
|
||||
try {
|
||||
if (session.extensionRunner.hasHandlers("session_shutdown")) {
|
||||
await waitBounded(
|
||||
await waitForChildOperation(
|
||||
session.extensionRunner.emit({
|
||||
type: "session_shutdown",
|
||||
reason: "quit",
|
||||
|
||||
@@ -19,10 +19,7 @@ import type {
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { Cause, Scope } from "effect";
|
||||
import { Effect, Queue, Stream } from "effect";
|
||||
@@ -34,21 +31,15 @@ import type {
|
||||
TranscriptPart,
|
||||
} from "../domain.ts";
|
||||
import { SendError, SpawnError } from "../domain.ts";
|
||||
import {
|
||||
bindChildSessionExtensions,
|
||||
childToolPolicy,
|
||||
createChildResources,
|
||||
shutdownAndDisposeChildSession,
|
||||
waitForChildOperation,
|
||||
} from "../../../shared/child-session.ts";
|
||||
import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts";
|
||||
|
||||
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Tools that headless children must not receive. Everything else stays enabled. */
|
||||
const CHILD_EXCLUDED_TOOL_NAMES = [
|
||||
"subagent_spawn",
|
||||
"subagent_wait",
|
||||
"subagent_cancel",
|
||||
"subagent_check",
|
||||
"subagent_list",
|
||||
"workflow",
|
||||
"ask_user",
|
||||
] as const;
|
||||
|
||||
// --- Model + effort resolution -----------------------------------------------
|
||||
|
||||
type ThinkingLevel = NonNullable<
|
||||
@@ -92,60 +83,6 @@ function resolvePiModel(
|
||||
throw new Error(`Unknown model "${hint}".`);
|
||||
}
|
||||
|
||||
// --- Child session helpers (ported from v1 shared/child-session.ts) -----------
|
||||
|
||||
/** Load normal global/package resources and trust-gated project resources. */
|
||||
async function createChildResources(cwd: string, projectTrusted: boolean) {
|
||||
const agentDir = getAgentDir();
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, {
|
||||
projectTrusted,
|
||||
});
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
|
||||
await loader.reload();
|
||||
return { loader, settingsManager };
|
||||
}
|
||||
|
||||
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
return Promise.race([
|
||||
operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
timeout,
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
/** Emit child session_shutdown (bounded), then dispose. Never throws. */
|
||||
async function shutdownAndDisposeChildSession(session: AgentSession) {
|
||||
try {
|
||||
if (session.extensionRunner.hasHandlers("session_shutdown")) {
|
||||
await waitBounded(
|
||||
session.extensionRunner.emit({
|
||||
type: "session_shutdown",
|
||||
reason: "quit",
|
||||
}),
|
||||
CHILD_SHUTDOWN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Extension runner inspection/emission is best-effort during teardown.
|
||||
} finally {
|
||||
try {
|
||||
session.dispose();
|
||||
} catch {
|
||||
// Disposal is terminal and must remain idempotent for callers.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event translation ----------------------------------------------------------
|
||||
|
||||
function messageRole(msg: unknown): Message["role"] | undefined {
|
||||
@@ -281,10 +218,10 @@ const makePiSession = (
|
||||
|
||||
const session = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { loader, settingsManager } = await createChildResources(
|
||||
task.cwd,
|
||||
task.parent.projectTrusted,
|
||||
);
|
||||
const { loader, settingsManager } = await createChildResources({
|
||||
cwd: task.cwd,
|
||||
projectTrusted: task.parent.projectTrusted,
|
||||
});
|
||||
const { session } = await createAgentSession({
|
||||
cwd: task.cwd,
|
||||
sessionManager: SessionManager.create(task.cwd),
|
||||
@@ -292,13 +229,13 @@ const makePiSession = (
|
||||
resourceLoader: loader,
|
||||
model,
|
||||
thinkingLevel,
|
||||
excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES],
|
||||
...childToolPolicy(),
|
||||
});
|
||||
// Start child extension session hooks/resources in headless mode.
|
||||
// A rejection here would otherwise leak the freshly created session:
|
||||
// the scope finalizer that owns cleanup is only registered later.
|
||||
try {
|
||||
await session.bindExtensions({ mode: "print" });
|
||||
await bindChildSessionExtensions(session);
|
||||
} catch (error) {
|
||||
await shutdownAndDisposeChildSession(session);
|
||||
throw error;
|
||||
@@ -493,7 +430,7 @@ const makePiSession = (
|
||||
} catch {
|
||||
// Continue with abort/dispose.
|
||||
}
|
||||
await waitBounded(session.abort(), CHILD_SHUTDOWN_TIMEOUT_MS);
|
||||
await waitForChildOperation(session.abort());
|
||||
await shutdownAndDisposeChildSession(session);
|
||||
Queue.endUnsafe(events);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user