feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 JB
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,5 @@
|
||||
# workflow
|
||||
|
||||
Persisted, gated feature workflows for Pi. The private Bun workspace retains the npm package name `@_jb/pi-workflow` under the repository's scoped package convention.
|
||||
|
||||
This workspace is currently private and must not be published. Its remaining runtime dependencies on the `_workflows` reference implementation must first be merged into this directory; see `docs/todos.md` at the repository root.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: feature-designer
|
||||
description: Turns discovery evidence and user decisions into an actionable implementation plan.
|
||||
roles: [design]
|
||||
tools: [read, grep, find, ls]
|
||||
selection:
|
||||
stages: [design]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# Feature designer
|
||||
|
||||
Design the smallest maintainable implementation that satisfies the request and follows repository patterns. Define acceptance criteria, ordered assignments, exact file ownership, verification commands, UI-impact classification, affected UI surfaces, concrete UI-impact evidence, recovery risks, and an independent review target. Record why each selected agent is eligible.
|
||||
|
||||
Emit exactly one eligible `implement` assignment, one eligible `verify` assignment, and one eligible independent `review` assignment. For `non-trivial` UI impact, also emit exactly one eligible `prototype` assignment whose owned paths isolate prototype artifacts from production implementation ownership. The prototype assignment is required even when the plan prose already describes prototypes. For `none` or `minor` UI impact, emit no prototype assignment.
|
||||
|
||||
The plan is complete only when every required assignment is present and an implementer can execute it without inventing missing contracts or crossing another assignment's ownership.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: feature-implementer
|
||||
description: Implements one approved assignment within declared file ownership.
|
||||
roles: [implement]
|
||||
tools: [read, grep, find, ls, edit, write]
|
||||
selection:
|
||||
stages: [implement]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# Feature implementer
|
||||
|
||||
Read the approved plan and implement only the assigned behavior within the declared owned paths. Follow existing project patterns, keep the change focused, and add tests for changed behavior. Report modified files, assumptions, and commands the host should run. The assignment is complete when the owned change satisfies its acceptance criteria without modifying files or Git state outside the assignment contract.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: feature-verifier
|
||||
description: Verifies approved behavior with focused tests, static checks, builds, and required browser evidence.
|
||||
roles: [verify]
|
||||
tools: [read, grep, find, ls]
|
||||
selection:
|
||||
stages: [verify]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# Feature verifier
|
||||
|
||||
Use `workflow_plan` repeatedly until every character range in the approved plan has been read. Compare that plan with the implementation evidence and inspect every required result from the host-provided command path. Confirm focused behavioral tests, type checks, linting, builds, and browser validation when applicable. Report each check and its actual outcome. Verification is complete only when every acceptance criterion has passing evidence or a precise failure that routes back to remediation.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: general-discovery
|
||||
description: Maps the repository, relevant behavior, constraints, risks, and unresolved questions before planning.
|
||||
roles: [discover]
|
||||
tools: [read, grep, find, ls]
|
||||
selection:
|
||||
stages: [discover]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# General discovery
|
||||
|
||||
Inspect the request and repository before proposing a design. Return relevant files and symbols, current behavior, integration points, constraints, risks, precedents, objective UI-impact evidence, and questions that cannot be answered from the repository. Distinguish verified facts from inferences. Discovery is complete when every requested behavior has a concrete codebase anchor or an explicit unresolved question.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: independent-reviewer
|
||||
description: Reviews the completed diff independently for specification conformance and repository standards.
|
||||
roles: [review]
|
||||
tools: [read, grep, find, ls]
|
||||
selection:
|
||||
stages: [review]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# Independent reviewer
|
||||
|
||||
Review the complete diff, approved plan, decisions, prototype evidence when applicable, and verification results without modifying the repository. Use `workflow_plan` and `workflow_diff` repeatedly until every character range reported by each tool has been read. Assess specification conformance and repository standards as separate axes. Report only concrete findings with locations, evidence, severity, and remediation. Review is complete when every changed behavior and owned file has been checked and the verdict is explicitly approved or changes requested.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: ui-prototyper
|
||||
description: Produces structurally distinct browser-reviewable prototypes for non-trivial UI work.
|
||||
roles: [prototype]
|
||||
tools: [read, grep, find, ls, edit, write]
|
||||
selection:
|
||||
stages: [prototype]
|
||||
anyTags: [frontend, ui, ux]
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: []
|
||||
---
|
||||
|
||||
# UI prototyper
|
||||
|
||||
Create the assigned throwaway prototype in the existing application context with realistic data and responsive behavior. Make its structure meaningfully different from the other candidates, document its rationale, and provide a route and launch instructions for browser review. Keep prototype-only behavior isolated and easy to remove. The candidate is complete when its unique path, route, rationale, and responsive checks are recorded. Use the browser to validate the exact candidate route at mobile (at most 480px wide) and desktop (at least 1024px wide) viewports. Save each screenshot inside the candidate-owned path and write a JSON report beside it with `{ "route": <exact route>, "result": "passed", "viewport": { "width": <number>, "height": <number> }, "screenshotPath": <repository-relative path> }`. Return the repository-relative JSON report paths in `browserEvidence`; self-reported prose is not evidence.
|
||||
@@ -0,0 +1,239 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import { loadWorkflowConfig } from "./config.ts";
|
||||
import { DEFAULT_WORKFLOW_CONFIG, MAX_CONCURRENCY } from "./domain.ts";
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-config-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(file: string, value: unknown) {
|
||||
await mkdir(path.dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
test("configuration defaults to concurrency 8 with immutable ceiling 32", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(config, DEFAULT_WORKFLOW_CONFIG);
|
||||
assert.equal(config.concurrency.ceiling, MAX_CONCURRENCY);
|
||||
assert.deepEqual(config.standardAgents, {
|
||||
enabled: true,
|
||||
roles: {
|
||||
scout: ["discover"],
|
||||
worker: ["implement"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a lowered ceiling clamps an inherited default", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { ceiling: 4 },
|
||||
});
|
||||
const globalOnly = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(globalOnly.concurrency, { default: 4, ceiling: 4 });
|
||||
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { default: 6, ceiling: 16 },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "workflow.json"), {
|
||||
concurrency: { ceiling: 3 },
|
||||
});
|
||||
const project = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: true,
|
||||
});
|
||||
assert.deepEqual(project.concurrency, { default: 3, ceiling: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
test("trusted project configuration layers over global configuration and may lower limits", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { default: 6, ceiling: 16 },
|
||||
maxAssignmentsPerRun: 64,
|
||||
analysisTools: ["read", "grep", "find"],
|
||||
worktrees: { root: ".worktrees/global" },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "workflow.json"), {
|
||||
concurrency: { default: 4, ceiling: 8 },
|
||||
maxReviewCycles: 5,
|
||||
analysisTools: ["read", "find", "ls"],
|
||||
worktrees: { root: ".worktrees/project" },
|
||||
});
|
||||
|
||||
const config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: true,
|
||||
invocationConcurrency: 7,
|
||||
});
|
||||
assert.deepEqual(config.concurrency, { default: 7, ceiling: 8 });
|
||||
assert.equal(config.maxAssignmentsPerRun, 64);
|
||||
assert.equal(config.maxReviewCycles, 5);
|
||||
assert.deepEqual(config.analysisTools, ["read", "find"]);
|
||||
assert.equal(config.worktrees.root, path.normalize(".worktrees/project"));
|
||||
});
|
||||
});
|
||||
|
||||
test("untrusted project configuration is never applied", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(directory, ".pi", "workflow.json"), {
|
||||
concurrency: { default: 1, ceiling: 1 },
|
||||
unknownField: true,
|
||||
});
|
||||
const config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(config.concurrency, { default: 8, ceiling: 32 });
|
||||
});
|
||||
});
|
||||
|
||||
test("trusted configuration can replace or disable standard-agent role overlays", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
standardAgents: {
|
||||
roles: {
|
||||
"codebase-analyzer": ["discover"],
|
||||
worker: ["implement", "verify"],
|
||||
},
|
||||
},
|
||||
});
|
||||
let config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(config.standardAgents.roles, {
|
||||
"codebase-analyzer": ["discover"],
|
||||
worker: ["implement", "verify"],
|
||||
});
|
||||
|
||||
await writeJson(path.join(directory, ".pi", "workflow.json"), {
|
||||
standardAgents: { enabled: false },
|
||||
});
|
||||
config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: true,
|
||||
});
|
||||
assert.equal(config.standardAgents.enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid standard-agent names and roles fail before a run starts", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
standardAgents: { roles: { Worker: ["implement"] } },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/lowercase kebab case/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
standardAgents: { roles: { worker: ["remediate"] } },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/unsupported role remediate/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("configuration cannot raise ceilings or activate unaudited tools", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { ceiling: 33 },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/between 1 and 32/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
analysisTools: ["read", "bash"],
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/unsupported tools: bash/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("project configuration cannot raise a lowered global ceiling", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { default: 4, ceiling: 8 },
|
||||
});
|
||||
await writeJson(path.join(directory, ".pi", "workflow.json"), {
|
||||
concurrency: { ceiling: 16 },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: true }),
|
||||
/cannot raise the current ceiling/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid numeric and path values fail before a run starts", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
maxAssignmentsPerRun: 1.5,
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/must be an integer/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
worktrees: { root: "../outside" },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/must stay inside the repository/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "workflow.json"), {
|
||||
concurrency: { default: 8, ceiling: 8 },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
invocationConcurrency: 9,
|
||||
}),
|
||||
/between 1 and 8/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
ANALYSIS_TOOL_ALLOWLIST,
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
MAX_ASSIGNMENTS_PER_RUN,
|
||||
MAX_CONCURRENCY,
|
||||
MAX_REVIEW_CYCLES,
|
||||
PROFILE_ROLES,
|
||||
type ProfileRole,
|
||||
type WorkflowConfig,
|
||||
} from "./domain.ts";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set([
|
||||
"concurrency",
|
||||
"maxAssignmentsPerRun",
|
||||
"maxReviewCycles",
|
||||
"analysisTools",
|
||||
"standardAgents",
|
||||
"worktrees",
|
||||
]);
|
||||
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
|
||||
const STANDARD_AGENT_KEYS = new Set(["enabled", "roles"]);
|
||||
const WORKTREE_KEYS = new Set(["root"]);
|
||||
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export interface LoadWorkflowConfigOptions {
|
||||
cwd: string;
|
||||
projectTrusted: boolean;
|
||||
agentDir?: string;
|
||||
invocationConcurrency?: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: ReadonlySet<string>,
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`${label}: unknown field${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function integerInRange(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
) {
|
||||
if (!Number.isSafeInteger(value) || typeof value !== "number") {
|
||||
throw new Error(`${label} must be an integer`);
|
||||
}
|
||||
if (value < minimum || value > maximum) {
|
||||
throw new Error(`${label} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, label: string) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((entry) => typeof entry !== "string")
|
||||
) {
|
||||
throw new Error(`${label} must be an array of strings`);
|
||||
}
|
||||
const normalized = value.map((entry) => entry.trim());
|
||||
if (normalized.some((entry) => entry.length === 0)) {
|
||||
throw new Error(`${label} cannot contain empty values`);
|
||||
}
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new Error(`${label} cannot contain duplicate values`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function worktreeRoot(value: unknown, label: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${label} must be a non-empty relative path`);
|
||||
}
|
||||
const normalized = path.normalize(value.trim());
|
||||
if (
|
||||
path.isAbsolute(normalized) ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith(`..${path.sep}`)
|
||||
) {
|
||||
throw new Error(`${label} must stay inside the repository`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function cloneConfig(config: WorkflowConfig): WorkflowConfig {
|
||||
return {
|
||||
concurrency: { ...config.concurrency },
|
||||
maxAssignmentsPerRun: config.maxAssignmentsPerRun,
|
||||
maxReviewCycles: config.maxReviewCycles,
|
||||
analysisTools: [...config.analysisTools],
|
||||
standardAgents: {
|
||||
enabled: config.standardAgents.enabled,
|
||||
roles: Object.fromEntries(
|
||||
Object.entries(config.standardAgents.roles).map(([name, roles]) => [
|
||||
name,
|
||||
[...roles],
|
||||
]),
|
||||
),
|
||||
},
|
||||
worktrees: { ...config.worktrees },
|
||||
};
|
||||
}
|
||||
|
||||
function applyConfigLayer(
|
||||
current: WorkflowConfig,
|
||||
value: unknown,
|
||||
label: string,
|
||||
): WorkflowConfig {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`${label} must contain a JSON object`);
|
||||
}
|
||||
assertKnownKeys(value, TOP_LEVEL_KEYS, label);
|
||||
const next = cloneConfig(current);
|
||||
|
||||
if (value.concurrency !== undefined) {
|
||||
if (!isRecord(value.concurrency)) {
|
||||
throw new Error(`${label}.concurrency must be an object`);
|
||||
}
|
||||
assertKnownKeys(
|
||||
value.concurrency,
|
||||
CONCURRENCY_KEYS,
|
||||
`${label}.concurrency`,
|
||||
);
|
||||
if (value.concurrency.ceiling !== undefined) {
|
||||
const ceiling = integerInRange(
|
||||
value.concurrency.ceiling,
|
||||
`${label}.concurrency.ceiling`,
|
||||
1,
|
||||
MAX_CONCURRENCY,
|
||||
);
|
||||
if (ceiling > current.concurrency.ceiling) {
|
||||
throw new Error(
|
||||
`${label}.concurrency.ceiling cannot raise the current ceiling`,
|
||||
);
|
||||
}
|
||||
next.concurrency.ceiling = ceiling;
|
||||
if (value.concurrency.default === undefined) {
|
||||
next.concurrency.default = Math.min(
|
||||
current.concurrency.default,
|
||||
ceiling,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.concurrency.default !== undefined) {
|
||||
next.concurrency.default = integerInRange(
|
||||
value.concurrency.default,
|
||||
`${label}.concurrency.default`,
|
||||
1,
|
||||
next.concurrency.ceiling,
|
||||
);
|
||||
}
|
||||
if (next.concurrency.default > next.concurrency.ceiling) {
|
||||
throw new Error(
|
||||
`${label}.concurrency.default exceeds the effective ceiling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.maxAssignmentsPerRun !== undefined) {
|
||||
next.maxAssignmentsPerRun = integerInRange(
|
||||
value.maxAssignmentsPerRun,
|
||||
`${label}.maxAssignmentsPerRun`,
|
||||
1,
|
||||
MAX_ASSIGNMENTS_PER_RUN,
|
||||
);
|
||||
}
|
||||
|
||||
if (value.maxReviewCycles !== undefined) {
|
||||
next.maxReviewCycles = integerInRange(
|
||||
value.maxReviewCycles,
|
||||
`${label}.maxReviewCycles`,
|
||||
1,
|
||||
MAX_REVIEW_CYCLES,
|
||||
);
|
||||
}
|
||||
|
||||
if (value.analysisTools !== undefined) {
|
||||
const requested = stringArray(
|
||||
value.analysisTools,
|
||||
`${label}.analysisTools`,
|
||||
);
|
||||
const invalid = requested.filter(
|
||||
(tool) => !ANALYSIS_TOOL_ALLOWLIST.includes(tool as never),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${label}.analysisTools contains unsupported tools: ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
next.analysisTools = current.analysisTools.filter((tool) =>
|
||||
requested.includes(tool),
|
||||
);
|
||||
}
|
||||
|
||||
if (value.standardAgents !== undefined) {
|
||||
if (!isRecord(value.standardAgents)) {
|
||||
throw new Error(`${label}.standardAgents must be an object`);
|
||||
}
|
||||
assertKnownKeys(
|
||||
value.standardAgents,
|
||||
STANDARD_AGENT_KEYS,
|
||||
`${label}.standardAgents`,
|
||||
);
|
||||
if (value.standardAgents.enabled !== undefined) {
|
||||
if (typeof value.standardAgents.enabled !== "boolean") {
|
||||
throw new Error(`${label}.standardAgents.enabled must be a boolean`);
|
||||
}
|
||||
next.standardAgents.enabled = value.standardAgents.enabled;
|
||||
}
|
||||
if (value.standardAgents.roles !== undefined) {
|
||||
if (!isRecord(value.standardAgents.roles)) {
|
||||
throw new Error(`${label}.standardAgents.roles must be an object`);
|
||||
}
|
||||
const roles: Record<string, ProfileRole[]> = {};
|
||||
for (const [name, rawRoles] of Object.entries(
|
||||
value.standardAgents.roles,
|
||||
)) {
|
||||
if (!PROFILE_NAME.test(name)) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles agent names must be lowercase kebab case`,
|
||||
);
|
||||
}
|
||||
const requested = stringArray(
|
||||
rawRoles,
|
||||
`${label}.standardAgents.roles.${name}`,
|
||||
);
|
||||
if (requested.length === 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} must not be empty`,
|
||||
);
|
||||
}
|
||||
const invalid = requested.filter(
|
||||
(role) => !PROFILE_ROLES.includes(role as ProfileRole),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} contains unsupported role ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
roles[name] = requested as ProfileRole[];
|
||||
}
|
||||
next.standardAgents.roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.worktrees !== undefined) {
|
||||
if (!isRecord(value.worktrees)) {
|
||||
throw new Error(`${label}.worktrees must be an object`);
|
||||
}
|
||||
assertKnownKeys(value.worktrees, WORKTREE_KEYS, `${label}.worktrees`);
|
||||
if (value.worktrees.root !== undefined) {
|
||||
next.worktrees.root = worktreeRoot(
|
||||
value.worktrees.root,
|
||||
`${label}.worktrees.root`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
async function readOptionalJson(file: string) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw new Error(`${file}: unable to read configuration`, { cause: error });
|
||||
}
|
||||
try {
|
||||
return JSON.parse(contents) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`${file}: invalid JSON`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWorkflowConfig(
|
||||
options: LoadWorkflowConfigOptions,
|
||||
): Promise<WorkflowConfig> {
|
||||
const agentDir = options.agentDir ?? getAgentDir();
|
||||
let config = cloneConfig(DEFAULT_WORKFLOW_CONFIG);
|
||||
|
||||
const globalFile = path.join(agentDir, "workflow.json");
|
||||
const globalValue = await readOptionalJson(globalFile);
|
||||
if (globalValue !== undefined) {
|
||||
config = applyConfigLayer(config, globalValue, globalFile);
|
||||
}
|
||||
|
||||
if (options.projectTrusted) {
|
||||
const projectFile = path.join(options.cwd, ".pi", "workflow.json");
|
||||
const projectValue = await readOptionalJson(projectFile);
|
||||
if (projectValue !== undefined) {
|
||||
config = applyConfigLayer(config, projectValue, projectFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.invocationConcurrency !== undefined) {
|
||||
config.concurrency.default = integerInRange(
|
||||
options.invocationConcurrency,
|
||||
"invocation concurrency",
|
||||
1,
|
||||
config.concurrency.ceiling,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,963 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { ImplementationContextState, WorkflowRun } from "./domain.ts";
|
||||
import {
|
||||
registerWorkflowContextTransitions,
|
||||
type ContextTransitionRuntime,
|
||||
} from "./context-transition.ts";
|
||||
import type { WorkflowCommand, WorkflowEngine, WorkflowView } from "./engine.ts";
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-context-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
type CommandHandler = (
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
) => Promise<void>;
|
||||
type ShutdownHandler = (
|
||||
event: { reason: string; targetSessionFile?: string },
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void>;
|
||||
type StartHandler = (
|
||||
event: { reason: string; previousSessionFile?: string },
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<void>;
|
||||
|
||||
function fakeApi() {
|
||||
let command: CommandHandler | undefined;
|
||||
let shutdown: ShutdownHandler | undefined;
|
||||
let start: StartHandler | undefined;
|
||||
const commandNames: string[] = [];
|
||||
const userMessages: string[] = [];
|
||||
const api = {
|
||||
registerCommand(name: string, options: { handler: CommandHandler }) {
|
||||
commandNames.push(name);
|
||||
command = options.handler;
|
||||
},
|
||||
sendUserMessage(message: string) {
|
||||
userMessages.push(message);
|
||||
},
|
||||
on(event: string, handler: ShutdownHandler | StartHandler) {
|
||||
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
|
||||
if (event === "session_start") start = handler as StartHandler;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
return {
|
||||
api,
|
||||
commandNames,
|
||||
userMessages,
|
||||
command: () => {
|
||||
assert.ok(command);
|
||||
return command;
|
||||
},
|
||||
shutdown: () => {
|
||||
assert.ok(shutdown);
|
||||
return shutdown;
|
||||
},
|
||||
start: () => {
|
||||
assert.ok(start);
|
||||
return start;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function workflowView(
|
||||
planPath: string,
|
||||
revision: number,
|
||||
context: ImplementationContextState,
|
||||
): WorkflowView {
|
||||
return {
|
||||
runId: "run-1",
|
||||
featureName: "example-feature",
|
||||
request: "Build example feature",
|
||||
revision,
|
||||
status: context.status === "ready" ? "running" : "waiting",
|
||||
background: false,
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/run-1",
|
||||
branch: "feature/example",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
openGate:
|
||||
context.status === "ready"
|
||||
? undefined
|
||||
: {
|
||||
id: context.gateId,
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 10,
|
||||
openedAt: 1,
|
||||
},
|
||||
planArtifact: {
|
||||
path: planPath,
|
||||
revision: 1,
|
||||
digest: createHash("sha256").update(readFileSync(planPath)).digest("hex"),
|
||||
},
|
||||
implementationContext: structuredClone(context),
|
||||
stages: [],
|
||||
assignments: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
};
|
||||
}
|
||||
|
||||
function commandContext(options: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
compact?: ExtensionCommandContext["compact"];
|
||||
newSession?: ExtensionCommandContext["newSession"];
|
||||
mode?: ExtensionCommandContext["mode"];
|
||||
hasUI?: boolean;
|
||||
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
|
||||
select?: (title: string, options: string[]) => Promise<string | undefined>;
|
||||
customResults?: unknown[];
|
||||
onCustomRender?: (lines: string[]) => void;
|
||||
}) {
|
||||
const notifications: string[] = [];
|
||||
const customResults = [...(options.customResults ?? [])];
|
||||
const overlayTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
const context = {
|
||||
cwd: "/repo",
|
||||
sessionManager: {
|
||||
getSessionId: () => options.sessionId,
|
||||
getSessionFile: () => options.sessionFile,
|
||||
},
|
||||
mode: options.mode ?? "print",
|
||||
hasUI: options.hasUI ?? false,
|
||||
ui: {
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
custom: async (
|
||||
factory: (
|
||||
tui: { requestRender(): void; terminal: { rows: number } },
|
||||
theme: typeof overlayTheme,
|
||||
keybindings: object,
|
||||
done: (value: unknown) => void,
|
||||
) => { render(width: number): string[] },
|
||||
) => {
|
||||
let completed: unknown;
|
||||
const component = factory(
|
||||
{ requestRender() {}, terminal: { rows: 24 } },
|
||||
overlayTheme,
|
||||
{},
|
||||
(value) => { completed = value; },
|
||||
);
|
||||
options.onCustomRender?.(component.render(100));
|
||||
return customResults.length > 0 ? customResults.shift() : completed;
|
||||
},
|
||||
input: options.input ?? (async () => undefined),
|
||||
select: options.select ?? (async () => undefined),
|
||||
},
|
||||
async waitForIdle() {},
|
||||
compact: options.compact ?? (() => {}),
|
||||
newSession:
|
||||
options.newSession ??
|
||||
(async () => ({ cancelled: false })) as ExtensionCommandContext["newSession"],
|
||||
} as unknown as ExtensionCommandContext;
|
||||
return { context, notifications };
|
||||
}
|
||||
|
||||
test("/workflow routes non-continuation arguments to the dashboard", async () => {
|
||||
const registered = fakeApi();
|
||||
const opened: string[] = [];
|
||||
registerWorkflowContextTransitions(
|
||||
registered.api,
|
||||
{
|
||||
store: {
|
||||
async listRuns() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
engineForContext: () => {
|
||||
throw new Error("dashboard should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime,
|
||||
async (args) => {
|
||||
opened.push(args);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
await registered.command()("run-1", commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
}).context);
|
||||
assert.deepEqual(opened, ["run-1"]);
|
||||
});
|
||||
|
||||
test("/workflow start routes the exact request through the assistant", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("start should route through the assistant");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
await registered.command()(
|
||||
"start Build searchable workflow cards",
|
||||
commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
}).context,
|
||||
);
|
||||
assert.equal(registered.userMessages.length, 1);
|
||||
assert.match(registered.userMessages[0]!, /invoke workflow/);
|
||||
assert.ok(
|
||||
registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the misspelled /worlflow alias is not registered", () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("command registration should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
assert.deepEqual(registered.commandNames, ["workflow"]);
|
||||
});
|
||||
|
||||
test("/workflow start prompts in the TUI and requires a message otherwise", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("start should route through the assistant");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
let prompt = "";
|
||||
await registered.command()(
|
||||
"start",
|
||||
commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
customResults: ["Add workflow lifecycle commands"],
|
||||
onCustomRender: (lines) => { prompt = lines.join("\n"); },
|
||||
}).context,
|
||||
);
|
||||
assert.match(prompt, /What do you want to start working on\?/);
|
||||
assert.ok(
|
||||
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
|
||||
);
|
||||
|
||||
const fallback = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("start", fallback.context);
|
||||
assert.ok(fallback.notifications.some((message) => message.includes("required")));
|
||||
});
|
||||
|
||||
test("/workflow continue lists only valid runs and resume is an alias", async () => {
|
||||
const validRun = (runId: string, activeParentSessionId = "session-1") =>
|
||||
({
|
||||
runId,
|
||||
revision: 10,
|
||||
status: "waiting",
|
||||
request: `Request ${runId}`,
|
||||
featureName: runId === "historical" ? "historical" : "shared-feature",
|
||||
parentSessionIds: [activeParentSessionId],
|
||||
lease: { activeParentSessionId },
|
||||
planArtifact: { path: "/plan.md", revision: 1, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: `gate-${runId}`,
|
||||
runRevision: 10,
|
||||
planRevision: 1,
|
||||
preparationId: `prep-${runId}`,
|
||||
activeParentSessionId,
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
id: `gate-${runId}`,
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 10,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
assignments: [],
|
||||
}) as unknown as WorkflowRun;
|
||||
const renderedSelections: string[] = [];
|
||||
const inspected: string[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
validRun("run-1"),
|
||||
validRun("run-2"),
|
||||
validRun("historical", "session-2"),
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
if (command.action === "inspect") {
|
||||
inspected.push(command.runId);
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 1,
|
||||
status: "running",
|
||||
background: false,
|
||||
workspace: { state: "ready", sourceRoot: "/repo" },
|
||||
stages: [],
|
||||
assignments: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
} as WorkflowView;
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const interactive = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
onCustomRender: (lines) => renderedSelections.push(lines.join("\n")),
|
||||
});
|
||||
await registered.command()("continue", interactive.context);
|
||||
await registered.command()("resume", interactive.context);
|
||||
assert.equal(renderedSelections.length, 2);
|
||||
for (const rendered of renderedSelections) {
|
||||
assert.match(rendered, /Continue Workflow Run/);
|
||||
assert.match(rendered, /shared-feature · run-1/);
|
||||
assert.match(rendered, /shared-feature · run-2/);
|
||||
assert.doesNotMatch(rendered, /historical/);
|
||||
}
|
||||
|
||||
await registered.command()("resume run-2", interactive.context);
|
||||
assert.deepEqual(inspected, ["run-2"]);
|
||||
});
|
||||
|
||||
test("/workflow continue without a run ID gives actionable non-UI guidance", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("non-UI fallback should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("continue", command.context);
|
||||
assert.ok(
|
||||
command.notifications.some((message) =>
|
||||
message.includes("/workflow continue <run-id>"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow continue reports the supplied run ID when inspection cannot load it", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute() {
|
||||
throw new Error("not found");
|
||||
},
|
||||
}) as unknown as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("continue missing-run-id", command.context);
|
||||
assert.ok(
|
||||
command.notifications.some((message) =>
|
||||
message.includes("Workflow Run missing-run-id could not be loaded"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow stop selects among active runs owned by the current session", async () => {
|
||||
const run = (
|
||||
runId: string,
|
||||
status: WorkflowRun["status"],
|
||||
activeParentSessionId: string,
|
||||
) =>
|
||||
({
|
||||
runId,
|
||||
revision: 4,
|
||||
status,
|
||||
request: `Request ${runId}`,
|
||||
featureName: "duplicate-label",
|
||||
parentSessionIds: [activeParentSessionId],
|
||||
lease: { activeParentSessionId },
|
||||
}) as unknown as WorkflowRun;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
run("run-1", "running", "session-1"),
|
||||
run("run-2", "waiting", "session-1"),
|
||||
run("completed", "completed", "session-1"),
|
||||
run("historical", "running", "session-2"),
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(command);
|
||||
if (command.action !== "inspect" && command.action !== "abort") {
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
}
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 4,
|
||||
status: command.action === "abort" ? "aborted" : "waiting",
|
||||
} as WorkflowView;
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
let choices = "";
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
customResults: [run("run-2", "waiting", "session-1")],
|
||||
onCustomRender: (lines) => { choices = lines.join("\n"); },
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.match(choices, /Stop Workflow Run/);
|
||||
assert.match(choices, /duplicate-label · run-1/);
|
||||
assert.match(choices, /duplicate-label · run-2/);
|
||||
assert.doesNotMatch(choices, /completed|historical/);
|
||||
assert.deepEqual(
|
||||
commands.map((entry) =>
|
||||
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
|
||||
),
|
||||
["inspect:run-2", "abort:run-2"],
|
||||
);
|
||||
const abort = commands[1];
|
||||
assert.match(
|
||||
abort?.action === "abort" ? abort.idempotencyKey : "",
|
||||
/^workflow-stop-run-2-4-/,
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow stop immediately stops the only active run", async () => {
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
{
|
||||
runId: "run-1",
|
||||
revision: 2,
|
||||
status: "running",
|
||||
request: "Only active run",
|
||||
lease: { activeParentSessionId: "session-1" },
|
||||
} as unknown as WorkflowRun,
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(command);
|
||||
if (command.action !== "inspect" && command.action !== "abort") {
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
}
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 2,
|
||||
status: command.action === "abort" ? "aborted" : "running",
|
||||
} as WorkflowView;
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.deepEqual(
|
||||
commands.map((entry) => entry.action),
|
||||
["inspect", "abort"],
|
||||
);
|
||||
assert.ok(command.notifications.some((message) => message.includes("stopped")));
|
||||
});
|
||||
|
||||
test("/workflow stop requires a run ID for multiple active runs without UI", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () =>
|
||||
["run-1", "run-2"].map(
|
||||
(runId) =>
|
||||
({
|
||||
runId,
|
||||
status: "running",
|
||||
request: runId,
|
||||
lease: { activeParentSessionId: "session-1" },
|
||||
}) as unknown as WorkflowRun,
|
||||
),
|
||||
},
|
||||
engineForContext: () => {
|
||||
throw new Error("selection fallback should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.ok(
|
||||
command.notifications.some((message) =>
|
||||
message.includes("/workflow stop <run-id>"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow continue invokes and fences normal compaction callbacks", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
await writeFile(planPath, "# Approved plan\n\nImplement safely.", "utf8");
|
||||
const context: ImplementationContextState = {
|
||||
gateId: "gate-1",
|
||||
runRevision: 10,
|
||||
planRevision: 1,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
sourceParentSessionId: "session-1",
|
||||
strategy: "smart-compact",
|
||||
status: "normal-compact-pending",
|
||||
method: "smart_compact",
|
||||
outcome: "unavailable",
|
||||
};
|
||||
let view = workflowView(planPath, 10, context);
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const engine = {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "inspect") return structuredClone(view);
|
||||
if (command.action === "continue-context") {
|
||||
const next = structuredClone(context);
|
||||
next.status = "normal-compact-running";
|
||||
next.method = "normal-compact";
|
||||
next.runRevision = 11;
|
||||
view = workflowView(planPath, 11, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
if (command.action === "complete-normal-compaction") {
|
||||
if (command.expectedRevision !== view.revision) {
|
||||
throw new Error("stale compaction callback");
|
||||
}
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.status = command.outcome === "failed" ? "failed" : "ready";
|
||||
next.outcome = command.outcome;
|
||||
view = workflowView(planPath, 12, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
} as WorkflowEngine;
|
||||
const runtime = {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => engine,
|
||||
} as unknown as ContextTransitionRuntime;
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, runtime);
|
||||
let compactOptions: Parameters<ExtensionCommandContext["compact"]>[0];
|
||||
const { context: ctx, notifications } = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
compact(options) {
|
||||
compactOptions = options;
|
||||
},
|
||||
});
|
||||
await registered.command()("continue run-1", ctx);
|
||||
assert.match(compactOptions?.customInstructions ?? "", /workflow run example-feature/);
|
||||
assert.match(compactOptions?.customInstructions ?? "", /Approved plan/);
|
||||
compactOptions?.onComplete?.({
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 100,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(
|
||||
commands.map((command) => command.action),
|
||||
["inspect", "continue-context", "complete-normal-compaction"],
|
||||
);
|
||||
const completed = commands.at(-1);
|
||||
assert.equal(
|
||||
completed?.action === "complete-normal-compaction"
|
||||
? completed.outcome
|
||||
: undefined,
|
||||
"succeeded",
|
||||
);
|
||||
compactOptions?.onComplete?.({
|
||||
summary: "duplicate",
|
||||
firstKeptEntryId: "entry-1",
|
||||
tokensBefore: 100,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.ok(notifications.some((message) => message.includes("stale compaction callback")));
|
||||
});
|
||||
});
|
||||
|
||||
test("cancelled fresh-session replacement clears the source-bound intent", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
await writeFile(planPath, "# Plan", "utf8");
|
||||
const context: ImplementationContextState = {
|
||||
gateId: "gate-1",
|
||||
runRevision: 10,
|
||||
planRevision: 1,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
sourceParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
};
|
||||
let view = workflowView(planPath, 10, context);
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const engine = {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "inspect") return structuredClone(view);
|
||||
if (command.action === "continue-context") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.sourceSessionFile = command.sourceSessionFile;
|
||||
next.runRevision = 11;
|
||||
view = workflowView(planPath, 11, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
if (command.action === "recover-context-intent") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.sourceSessionFile = undefined;
|
||||
next.intentCreatedAt = undefined;
|
||||
next.intentExpiresAt = undefined;
|
||||
next.runRevision = 12;
|
||||
view = workflowView(planPath, 12, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
} as WorkflowEngine;
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => engine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
newSession: (async () => ({ cancelled: true })) as ExtensionCommandContext["newSession"],
|
||||
});
|
||||
await registered.command()("continue run-1", command.context);
|
||||
assert.deepEqual(
|
||||
commands.map((entry) => entry.action),
|
||||
["inspect", "continue-context", "recover-context-intent"],
|
||||
);
|
||||
assert.equal(view.implementationContext?.sourceSessionFile, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test("dashboard continuation immediately enters the fresh-session transition", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
await writeFile(planPath, "# Plan", "utf8");
|
||||
const implementationContext: ImplementationContextState = {
|
||||
gateId: "gate-1",
|
||||
runRevision: 10,
|
||||
planRevision: 1,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
sourceParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
};
|
||||
let view = workflowView(planPath, 10, implementationContext);
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const engine = {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "inspect") return structuredClone(view);
|
||||
if (command.action === "continue-context") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.sourceSessionFile = command.sourceSessionFile;
|
||||
next.runRevision = 11;
|
||||
view = workflowView(planPath, 11, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
if (command.action === "recover-context-intent") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.sourceSessionFile = undefined;
|
||||
next.runRevision = 12;
|
||||
view = workflowView(planPath, 12, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
} as WorkflowEngine;
|
||||
const registered = fakeApi();
|
||||
const dashboardArguments: string[] = [];
|
||||
registerWorkflowContextTransitions(
|
||||
registered.api,
|
||||
{
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => engine,
|
||||
} as unknown as ContextTransitionRuntime,
|
||||
async (args) => {
|
||||
dashboardArguments.push(args);
|
||||
return { continueRunId: "run-1" };
|
||||
},
|
||||
);
|
||||
let newSessionCalls = 0;
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
newSession: (async () => {
|
||||
newSessionCalls++;
|
||||
return { cancelled: true };
|
||||
}) as ExtensionCommandContext["newSession"],
|
||||
});
|
||||
await registered.command()("run-1", command.context);
|
||||
assert.deepEqual(dashboardArguments, ["run-1"]);
|
||||
assert.equal(newSessionCalls, 1);
|
||||
assert.deepEqual(
|
||||
commands.map((entry) => entry.action),
|
||||
["inspect", "continue-context", "recover-context-intent"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a later startup recovers an unconsumed claimed handoff", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
await writeFile(planPath, "# Plan", "utf8");
|
||||
const context: ImplementationContextState = {
|
||||
gateId: "gate-1",
|
||||
runRevision: 30,
|
||||
planRevision: 1,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
sourceParentSessionId: "session-1",
|
||||
sourceSessionFile: "/sessions/source.jsonl",
|
||||
targetSessionFile: "/sessions/target.jsonl",
|
||||
strategy: "fresh-session",
|
||||
status: "claimed-target-awaiting-withSession",
|
||||
method: "fresh-session",
|
||||
intentExpiresAt: Date.now() + 60_000,
|
||||
};
|
||||
let view = workflowView(planPath, 30, context);
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const engine = {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "recover-context-intent") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.status = "handoff-pending";
|
||||
next.targetSessionFile = undefined;
|
||||
next.runRevision = 31;
|
||||
view = workflowView(planPath, 31, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
} as WorkflowEngine;
|
||||
const runtime = {
|
||||
store: {
|
||||
async listRuns() {
|
||||
return [
|
||||
{
|
||||
runId: view.runId,
|
||||
revision: view.revision,
|
||||
status: view.status,
|
||||
implementationContext: structuredClone(view.implementationContext),
|
||||
} as WorkflowRun,
|
||||
];
|
||||
},
|
||||
},
|
||||
engineForContext: () => engine,
|
||||
} as unknown as ContextTransitionRuntime;
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, runtime);
|
||||
const target = commandContext({
|
||||
sessionId: "session-2",
|
||||
sessionFile: "/sessions/target.jsonl",
|
||||
}).context;
|
||||
for (const scenario of [
|
||||
{ eventReason: "startup", expectedReason: "startup", expired: false },
|
||||
{ eventReason: "reload", expectedReason: "reload", expired: false },
|
||||
{ eventReason: "resume", expectedReason: "resume", expired: false },
|
||||
{ eventReason: "fork", expectedReason: "fork", expired: false },
|
||||
{ eventReason: "startup", expectedReason: "expired", expired: true },
|
||||
] as const) {
|
||||
const pending = structuredClone(context);
|
||||
pending.intentExpiresAt = scenario.expired
|
||||
? Date.now() - 1
|
||||
: Date.now() + 60_000;
|
||||
view = workflowView(planPath, 30, pending);
|
||||
commands.length = 0;
|
||||
await registered.start()({ reason: scenario.eventReason }, target);
|
||||
const recovered = commands[0];
|
||||
assert.equal(recovered?.action, "recover-context-intent");
|
||||
assert.equal(
|
||||
recovered?.action === "recover-context-intent"
|
||||
? recovered.reason
|
||||
: undefined,
|
||||
scenario.expectedReason,
|
||||
);
|
||||
assert.equal(view.implementationContext?.status, "handoff-pending");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("fresh continuation claims shutdown target and consumes only in withSession", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
const planPrefix = "# Handoff plan\n";
|
||||
const boundaryPlan = `${planPrefix}${"a".repeat(
|
||||
32 * 1024 - 1 - Buffer.byteLength(planPrefix, "utf8"),
|
||||
)}😀tail`;
|
||||
await writeFile(planPath, boundaryPlan, "utf8");
|
||||
const context: ImplementationContextState = {
|
||||
gateId: "gate-1",
|
||||
runRevision: 20,
|
||||
planRevision: 1,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
sourceParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
};
|
||||
let view = workflowView(planPath, 20, context);
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const sent: unknown[] = [];
|
||||
const engine = {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "inspect") return structuredClone(view);
|
||||
if (command.action === "continue-context") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.sourceSessionFile = command.sourceSessionFile;
|
||||
next.runRevision = 21;
|
||||
view = workflowView(planPath, 21, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
if (command.action === "claim-context-handoff") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.status = "claimed-target-awaiting-withSession";
|
||||
next.targetSessionFile = command.targetSessionFile;
|
||||
next.runRevision = 22;
|
||||
view = workflowView(planPath, 22, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
if (command.action === "consume-context-handoff") {
|
||||
const next = structuredClone(view.implementationContext!);
|
||||
next.status = "ready";
|
||||
next.activeParentSessionId = "session-2";
|
||||
next.runRevision = 23;
|
||||
view = workflowView(planPath, 23, next);
|
||||
return structuredClone(view);
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
} as WorkflowEngine;
|
||||
const runtime = {
|
||||
store: {
|
||||
async listRuns() {
|
||||
return [
|
||||
{
|
||||
runId: view.runId,
|
||||
revision: view.revision,
|
||||
status: view.status,
|
||||
implementationContext: structuredClone(view.implementationContext),
|
||||
} as WorkflowRun,
|
||||
];
|
||||
},
|
||||
},
|
||||
engineForContext: () => engine,
|
||||
} as unknown as ContextTransitionRuntime;
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, runtime);
|
||||
const replacement = commandContext({
|
||||
sessionId: "session-2",
|
||||
sessionFile: "/sessions/target.jsonl",
|
||||
}).context as unknown as ExtensionCommandContext & {
|
||||
sendMessage(message: unknown): Promise<void>;
|
||||
sendUserMessage(message: string): Promise<void>;
|
||||
};
|
||||
replacement.sendMessage = async (message) => {
|
||||
sent.push(message);
|
||||
};
|
||||
replacement.sendUserMessage = async () => {};
|
||||
const old = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
newSession: (async (options) => {
|
||||
await registered.shutdown()(
|
||||
{ reason: "new", targetSessionFile: "/sessions/target.jsonl" },
|
||||
old.context,
|
||||
);
|
||||
await registered.start()(
|
||||
{ reason: "new", previousSessionFile: "/sessions/source.jsonl" },
|
||||
replacement,
|
||||
);
|
||||
await options?.withSession?.(replacement);
|
||||
return { cancelled: false };
|
||||
}) as ExtensionCommandContext["newSession"],
|
||||
});
|
||||
await registered.command()("continue run-1", old.context);
|
||||
assert.equal(sent.length, 1);
|
||||
const handoffMessage = sent[0] as { content: string };
|
||||
assert.match(handoffMessage.content, /Continue workflow run example-feature/);
|
||||
const handoff = JSON.stringify(sent[0]);
|
||||
assert.match(handoff, /Handoff plan/);
|
||||
assert.doesNotMatch(handoff, /�|😀/);
|
||||
assert.deepEqual(
|
||||
commands.map((command) => command.action),
|
||||
[
|
||||
"inspect",
|
||||
"continue-context",
|
||||
"claim-context-handoff",
|
||||
"inspect",
|
||||
"consume-context-handoff",
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { WorkflowRun } from "./domain.ts";
|
||||
import {
|
||||
type CompleteNormalCompactionCommand,
|
||||
type ConsumeContextHandoffCommand,
|
||||
type ContinueContextCommand,
|
||||
type DecideWorkflowCommand,
|
||||
type WorkflowCommand,
|
||||
type WorkflowEngine,
|
||||
type WorkflowView,
|
||||
} from "./engine.ts";
|
||||
import { isValidContextContinuation } from "./continuation.ts";
|
||||
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
|
||||
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
|
||||
import { workflowInput, workflowSelect } from "./tui.ts";
|
||||
|
||||
const MAX_HANDOFF_BYTES = 32 * 1024;
|
||||
type NewSessionOptions = NonNullable<
|
||||
Parameters<ExtensionCommandContext["newSession"]>[0]
|
||||
>;
|
||||
type ReplacementContext = Parameters<
|
||||
NonNullable<NewSessionOptions["withSession"]>
|
||||
>[0];
|
||||
|
||||
export interface ContextTransitionRuntime {
|
||||
store: WorkflowStore;
|
||||
engineForContext(ctx: ExtensionContext): Promise<WorkflowEngine> | WorkflowEngine;
|
||||
}
|
||||
|
||||
function sessionId(ctx: ExtensionContext) {
|
||||
const id = ctx.sessionManager.getSessionId();
|
||||
if (!id) throw new Error("Workflow context transition requires a persisted session ID");
|
||||
return id;
|
||||
}
|
||||
|
||||
function sessionFile(ctx: ExtensionContext) {
|
||||
const file = ctx.sessionManager.getSessionFile();
|
||||
if (!file) throw new Error("Workflow context transition requires a persisted session file");
|
||||
return file;
|
||||
}
|
||||
|
||||
function contextCommand(view: WorkflowView) {
|
||||
const context = view.implementationContext;
|
||||
if (!context) throw new Error("Workflow Run has no Implementation Context intent");
|
||||
return {
|
||||
runId: view.runId,
|
||||
gateId: context.gateId,
|
||||
preparationId: context.preparationId,
|
||||
planRevision: context.planRevision,
|
||||
contextRevision: context.runRevision,
|
||||
expectedRevision: view.revision,
|
||||
};
|
||||
}
|
||||
|
||||
async function boundedPlan(view: WorkflowView) {
|
||||
if (!view.planArtifact) throw new Error("Workflow Run has no persisted plan");
|
||||
const plan = await readVerifiedPlanArtifact(view.planArtifact);
|
||||
let bytes = 0;
|
||||
const characters: string[] = [];
|
||||
for (const character of plan) {
|
||||
const width = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + width > MAX_HANDOFF_BYTES) break;
|
||||
characters.push(character);
|
||||
bytes += width;
|
||||
}
|
||||
return characters.join("");
|
||||
}
|
||||
|
||||
function handoffMessage(view: WorkflowView, plan: string) {
|
||||
return [
|
||||
`Continue workflow run ${deriveRunLabel(view)}.`,
|
||||
`Worktree: ${view.workspace.root ?? "missing"}`,
|
||||
`Plan revision: ${view.planArtifact?.revision ?? "missing"}`,
|
||||
"The workflow state remains authoritative. Inspect it before taking action.",
|
||||
"",
|
||||
plan,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function inspect(
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionContext,
|
||||
runId: string,
|
||||
) {
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
return {
|
||||
engine,
|
||||
view: await engine.execute({ action: "inspect", runId }),
|
||||
};
|
||||
}
|
||||
|
||||
async function completeNormalCompaction(
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionContext,
|
||||
view: WorkflowView,
|
||||
outcome: CompleteNormalCompactionCommand["outcome"],
|
||||
error?: string,
|
||||
) {
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
const command: CompleteNormalCompactionCommand = {
|
||||
action: "complete-normal-compaction",
|
||||
...contextCommand(view),
|
||||
outcome,
|
||||
...(error ? { error } : {}),
|
||||
};
|
||||
await engine.execute(command);
|
||||
}
|
||||
|
||||
async function consumeHandoff(
|
||||
runtime: ContextTransitionRuntime,
|
||||
replacementCtx: ReplacementContext,
|
||||
runId: string,
|
||||
sourceSessionFile: string,
|
||||
) {
|
||||
const current = await inspect(runtime, replacementCtx, runId);
|
||||
const targetSessionFile = sessionFile(replacementCtx);
|
||||
const plan = await boundedPlan(current.view);
|
||||
await replacementCtx.sendMessage({
|
||||
customType: "workflow-handoff",
|
||||
content: handoffMessage(current.view, plan),
|
||||
display: true,
|
||||
});
|
||||
const command: ConsumeContextHandoffCommand = {
|
||||
action: "consume-context-handoff",
|
||||
...contextCommand(current.view),
|
||||
targetSessionFile,
|
||||
previousSessionFile: sourceSessionFile,
|
||||
};
|
||||
await current.engine.execute(command);
|
||||
replacementCtx.ui.notify(
|
||||
`Workflow ${deriveRunLabel(current.view)} context handoff is ready`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
async function continueFreshSession(
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionCommandContext,
|
||||
view: WorkflowView,
|
||||
) {
|
||||
const currentSessionId = sessionId(ctx);
|
||||
const sourceSessionFile = sessionFile(ctx);
|
||||
if (view.implementationContext?.activeParentSessionId !== currentSessionId) {
|
||||
throw new Error("Only the active parent session may continue this Workflow Run");
|
||||
}
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
const command: ContinueContextCommand = {
|
||||
action: "continue-context",
|
||||
...contextCommand(view),
|
||||
sourceSessionFile,
|
||||
};
|
||||
const pending = await engine.execute(command);
|
||||
const result = await ctx.newSession({
|
||||
parentSession: sourceSessionFile,
|
||||
withSession: async (replacementCtx) => {
|
||||
await consumeHandoff(runtime, replacementCtx, view.runId, sourceSessionFile);
|
||||
},
|
||||
});
|
||||
if (result.cancelled) {
|
||||
const pendingContext = pending.implementationContext;
|
||||
if (pendingContext) {
|
||||
await engine.execute({
|
||||
action: "recover-context-intent",
|
||||
runId: pending.runId,
|
||||
gateId: pendingContext.gateId,
|
||||
preparationId: pendingContext.preparationId,
|
||||
planRevision: pendingContext.planRevision,
|
||||
contextRevision: pendingContext.runRevision,
|
||||
expectedRevision: pending.revision,
|
||||
reason: "cancelled",
|
||||
});
|
||||
}
|
||||
ctx.ui.notify("Workflow context handoff was cancelled", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
async function continueNormalCompaction(
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionCommandContext,
|
||||
view: WorkflowView,
|
||||
) {
|
||||
const plan = await boundedPlan(view);
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
const command: ContinueContextCommand = {
|
||||
action: "continue-context",
|
||||
...contextCommand(view),
|
||||
};
|
||||
const compacting = await engine.execute(command);
|
||||
const instructions = [
|
||||
`Prepare context for workflow run ${deriveRunLabel(view)}.`,
|
||||
`Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`,
|
||||
"Use this approved plan as the implementation focus:",
|
||||
plan,
|
||||
].join("\n\n");
|
||||
try {
|
||||
ctx.compact({
|
||||
customInstructions: instructions,
|
||||
onComplete: () => {
|
||||
void completeNormalCompaction(runtime, ctx, compacting, "succeeded").catch(
|
||||
(error: unknown) => {
|
||||
ctx.ui.notify(
|
||||
`Workflow compaction callback failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
void completeNormalCompaction(
|
||||
runtime,
|
||||
ctx,
|
||||
compacting,
|
||||
"failed",
|
||||
error.message || "Normal compaction failed",
|
||||
).catch((callbackError: unknown) => {
|
||||
ctx.ui.notify(
|
||||
`Workflow compaction callback failed: ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`,
|
||||
"error",
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await completeNormalCompaction(
|
||||
runtime,
|
||||
ctx,
|
||||
compacting,
|
||||
"failed",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isInteractive(ctx: ExtensionCommandContext) {
|
||||
return ctx.hasUI && ctx.mode === "tui";
|
||||
}
|
||||
|
||||
function belongsToActiveParent(run: WorkflowRun, sessionId: string) {
|
||||
return run.lease?.activeParentSessionId === sessionId;
|
||||
}
|
||||
|
||||
function canContinue(run: WorkflowRun, currentSessionId: string) {
|
||||
return isValidContextContinuation(
|
||||
{
|
||||
...run,
|
||||
openGate: run.gates.find((gate) => gate.status === "open"),
|
||||
},
|
||||
currentSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function selectRun(
|
||||
ctx: ExtensionCommandContext,
|
||||
title: string,
|
||||
runs: WorkflowRun[],
|
||||
) {
|
||||
const labels = deriveRunLabels(runs);
|
||||
return workflowSelect(
|
||||
ctx,
|
||||
title,
|
||||
runs.map((run, index) => ({
|
||||
value: run,
|
||||
label: labels[index]!,
|
||||
description: run.status,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function continueRun(
|
||||
runtime: ContextTransitionRuntime,
|
||||
runId: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
) {
|
||||
await ctx.waitForIdle();
|
||||
let view: WorkflowView;
|
||||
try {
|
||||
({ view } = await inspect(runtime, ctx, runId));
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Workflow Run ${runId} could not be loaded: ${reason}`);
|
||||
}
|
||||
const status = view.implementationContext?.status;
|
||||
if (status === "handoff-pending") {
|
||||
await continueFreshSession(runtime, ctx, view);
|
||||
return;
|
||||
}
|
||||
if (status === "normal-compact-pending") {
|
||||
await continueNormalCompaction(runtime, ctx, view);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Workflow Run ${deriveRunLabel(view)} has no pending context continuation`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleContinue(
|
||||
runtime: ContextTransitionRuntime,
|
||||
runId: string | undefined,
|
||||
ctx: ExtensionCommandContext,
|
||||
) {
|
||||
if (runId) {
|
||||
await continueRun(runtime, runId, ctx);
|
||||
return;
|
||||
}
|
||||
if (!isInteractive(ctx)) {
|
||||
ctx.ui.notify(
|
||||
"Selecting a Workflow Run to continue requires interactive UI. Use /workflow continue <run-id>.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const currentSessionId = sessionId(ctx);
|
||||
const runs = (await runtime.store.listRuns()).filter((run) =>
|
||||
canContinue(run, currentSessionId),
|
||||
);
|
||||
if (runs.length === 0) {
|
||||
ctx.ui.notify("No Workflow Runs are ready to continue.", "info");
|
||||
return;
|
||||
}
|
||||
const selected = await selectRun(ctx, "Continue Workflow Run", runs);
|
||||
if (selected) await continueRun(runtime, selected.runId, ctx);
|
||||
}
|
||||
|
||||
async function handleStart(
|
||||
pi: ExtensionAPI,
|
||||
request: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
) {
|
||||
let initialRequest = request.trim();
|
||||
if (!initialRequest) {
|
||||
if (!isInteractive(ctx)) {
|
||||
ctx.ui.notify(
|
||||
"A workflow request is required outside interactive UI. Use /workflow start <message>.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
initialRequest =
|
||||
(await workflowInput(
|
||||
ctx,
|
||||
"Start Workflow Run",
|
||||
"What do you want to start working on?",
|
||||
))?.trim() ?? "";
|
||||
if (!initialRequest) return;
|
||||
}
|
||||
await ctx.waitForIdle();
|
||||
await pi.sendUserMessage(
|
||||
[
|
||||
"Start a managed workflow for the request below.",
|
||||
"Use the request text verbatim as the initial workflow request, choose a descriptive lowercase kebab-case featureName, create a unique idempotencyKey, and invoke workflow with action \"start\" through the normal assistant tool path.",
|
||||
"",
|
||||
initialRequest,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStop(
|
||||
runtime: ContextTransitionRuntime,
|
||||
requestedRunId: string | undefined,
|
||||
ctx: ExtensionCommandContext,
|
||||
) {
|
||||
const currentSessionId = sessionId(ctx);
|
||||
const activeRuns = (await runtime.store.listRuns()).filter(
|
||||
(run) =>
|
||||
(run.status === "running" || run.status === "waiting") &&
|
||||
belongsToActiveParent(run, currentSessionId),
|
||||
);
|
||||
let selected = requestedRunId
|
||||
? activeRuns.find((run) => run.runId === requestedRunId)
|
||||
: activeRuns.length === 1
|
||||
? activeRuns[0]
|
||||
: undefined;
|
||||
if (requestedRunId && !selected) {
|
||||
ctx.ui.notify(
|
||||
`Workflow Run ${requestedRunId} is not active for this session.`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!requestedRunId && activeRuns.length === 0) {
|
||||
ctx.ui.notify("No active Workflow Runs for this session.", "info");
|
||||
return;
|
||||
}
|
||||
if (!selected && !isInteractive(ctx)) {
|
||||
ctx.ui.notify(
|
||||
"Selecting a Workflow Run to stop requires interactive UI. Use /workflow stop <run-id>.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
selected ??= await selectRun(ctx, "Stop Workflow Run", activeRuns);
|
||||
if (!selected) return;
|
||||
await ctx.waitForIdle();
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
const current = await engine.execute({ action: "inspect", runId: selected.runId });
|
||||
await engine.execute({
|
||||
action: "abort",
|
||||
runId: current.runId,
|
||||
expectedRevision: current.revision,
|
||||
idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`,
|
||||
});
|
||||
ctx.ui.notify(`Workflow ${deriveRunLabel(current)} stopped.`, "info");
|
||||
}
|
||||
|
||||
export function executeHostDecision(
|
||||
pi: Pick<ExtensionAPI, "getActiveTools">,
|
||||
engine: WorkflowEngine,
|
||||
command: DecideWorkflowCommand,
|
||||
) {
|
||||
return engine.execute({
|
||||
...command,
|
||||
...(command.decision.kind === "report-context-preparation"
|
||||
? { activeToolNames: pi.getActiveTools() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function registerWorkflowContextTransitions(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
openDashboard?: (
|
||||
args: string,
|
||||
ctx: ExtensionCommandContext,
|
||||
) => Promise<{ continueRunId?: string }>,
|
||||
) {
|
||||
pi.registerCommand("workflow", {
|
||||
description: "Start, stop, inspect, or continue persisted workflow runs",
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const input = args.trim();
|
||||
const command = input.match(/^(start|stop|continue|resume)(?:\s+([\s\S]*))?$/);
|
||||
if (command?.[1] === "start") {
|
||||
await handleStart(pi, command[2] ?? "", ctx);
|
||||
return;
|
||||
}
|
||||
if (command?.[1] === "stop") {
|
||||
const runId = command[2]?.trim();
|
||||
if (runId && /\s/.test(runId)) {
|
||||
ctx.ui.notify("Usage: /workflow stop [run-id]", "error");
|
||||
return;
|
||||
}
|
||||
await handleStop(runtime, runId || undefined, ctx);
|
||||
return;
|
||||
}
|
||||
if (command?.[1] === "continue" || command?.[1] === "resume") {
|
||||
const runId = command[2]?.trim();
|
||||
if (runId && /\s/.test(runId)) {
|
||||
ctx.ui.notify(
|
||||
`Usage: /workflow ${command[1]} [run-id]`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await handleContinue(runtime, runId || undefined, ctx);
|
||||
return;
|
||||
}
|
||||
if (openDashboard) {
|
||||
const result = await openDashboard(input, ctx);
|
||||
if (result.continueRunId) {
|
||||
await handleContinue(runtime, result.continueRunId, ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await handleContinue(runtime, undefined, ctx);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||
if (!currentSessionFile) return;
|
||||
for (const run of await runtime.store.listRuns()) {
|
||||
const context = run.implementationContext;
|
||||
if (run.status !== "waiting" || context?.status !== "claimed-target-awaiting-withSession") {
|
||||
continue;
|
||||
}
|
||||
const immediateTarget =
|
||||
event.reason === "new" &&
|
||||
event.previousSessionFile === context.sourceSessionFile &&
|
||||
currentSessionFile === context.targetSessionFile &&
|
||||
(context.intentExpiresAt === undefined || context.intentExpiresAt >= Date.now());
|
||||
if (immediateTarget) continue;
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
try {
|
||||
await engine.execute({
|
||||
action: "recover-context-intent",
|
||||
runId: run.runId,
|
||||
gateId: context.gateId,
|
||||
preparationId: context.preparationId,
|
||||
planRevision: context.planRevision,
|
||||
contextRevision: context.runRevision,
|
||||
expectedRevision: run.revision,
|
||||
reason:
|
||||
context.intentExpiresAt !== undefined && context.intentExpiresAt < Date.now()
|
||||
? "expired"
|
||||
: event.reason === "reload" ||
|
||||
event.reason === "resume" ||
|
||||
event.reason === "fork"
|
||||
? event.reason
|
||||
: "startup",
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!/owned by live process/.test(error.message)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (event, ctx) => {
|
||||
if (event.reason !== "new" || !event.targetSessionFile) return;
|
||||
const sourceSessionFile = ctx.sessionManager.getSessionFile();
|
||||
const sourceSessionId = ctx.sessionManager.getSessionId();
|
||||
if (!sourceSessionFile || !sourceSessionId) return;
|
||||
for (const run of await runtime.store.listRuns()) {
|
||||
const context = run.implementationContext;
|
||||
if (
|
||||
run.status !== "waiting" ||
|
||||
context?.status !== "handoff-pending" ||
|
||||
context.sourceParentSessionId !== sourceSessionId ||
|
||||
context.sourceSessionFile !== sourceSessionFile
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
await engine.execute({
|
||||
action: "claim-context-handoff",
|
||||
runId: run.runId,
|
||||
gateId: context.gateId,
|
||||
preparationId: context.preparationId,
|
||||
planRevision: context.planRevision,
|
||||
contextRevision: context.runRevision,
|
||||
expectedRevision: run.revision,
|
||||
sourceSessionFile,
|
||||
targetSessionFile: event.targetSessionFile,
|
||||
reason: "new",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type ContextTransitionCommand = Extract<
|
||||
WorkflowCommand,
|
||||
| { action: "continue-context" }
|
||||
| { action: "claim-context-handoff" }
|
||||
| { action: "consume-context-handoff" }
|
||||
| { action: "complete-normal-compaction" }
|
||||
| { action: "recover-context-intent" }
|
||||
>;
|
||||
|
||||
export function contextTransitionState(run: WorkflowRun) {
|
||||
return run.implementationContext;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isValidContextContinuation } from "./continuation.ts";
|
||||
|
||||
function state(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
revision: 5,
|
||||
status: "waiting",
|
||||
planArtifact: { revision: 2 },
|
||||
openGate: {
|
||||
id: "gate-1",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
},
|
||||
implementationContext: {
|
||||
gateId: "gate-1",
|
||||
runRevision: 4,
|
||||
planRevision: 2,
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
assignments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("continuation requires every persisted binding and the active parent", () => {
|
||||
assert.equal(isValidContextContinuation(state(), "session-1"), true);
|
||||
assert.equal(isValidContextContinuation(state(), "session-2"), false);
|
||||
assert.equal(isValidContextContinuation(state({ status: "running" }), "session-1"), false);
|
||||
assert.equal(
|
||||
isValidContextContinuation(
|
||||
state({
|
||||
assignments: [
|
||||
{
|
||||
status: "queued",
|
||||
executionToken: "active-token",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"session-1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isValidContextContinuation(
|
||||
state({
|
||||
revision: 6,
|
||||
}),
|
||||
"session-1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isValidContextContinuation(
|
||||
state({
|
||||
implementationContext: {
|
||||
...state().implementationContext,
|
||||
strategy: "smart-compact",
|
||||
},
|
||||
}),
|
||||
"session-1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("normal compaction continuation requires unavailable smart compaction state", () => {
|
||||
const context = {
|
||||
...state().implementationContext,
|
||||
strategy: "smart-compact",
|
||||
status: "normal-compact-pending",
|
||||
method: "smart_compact",
|
||||
outcome: "unavailable",
|
||||
};
|
||||
assert.equal(
|
||||
isValidContextContinuation(
|
||||
state({ implementationContext: context }),
|
||||
"session-1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isValidContextContinuation(
|
||||
state({
|
||||
implementationContext: { ...context, outcome: "failed" },
|
||||
}),
|
||||
"session-1",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
interface ContinuationAssignment {
|
||||
status: string;
|
||||
executionToken?: string;
|
||||
}
|
||||
|
||||
interface ContinuationGate {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ContinuationContext {
|
||||
gateId: string;
|
||||
runRevision: number;
|
||||
planRevision: number;
|
||||
activeParentSessionId: string;
|
||||
strategy?: string;
|
||||
status: string;
|
||||
method?: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export interface ContinuationState {
|
||||
revision: number;
|
||||
status: string;
|
||||
planArtifact?: { revision: number };
|
||||
implementationContext?: ContinuationContext;
|
||||
openGate?: ContinuationGate;
|
||||
assignments: ContinuationAssignment[];
|
||||
}
|
||||
|
||||
export function isValidContextContinuation(
|
||||
run: ContinuationState,
|
||||
sessionId?: string,
|
||||
) {
|
||||
const context = run.implementationContext;
|
||||
const gate = run.openGate;
|
||||
if (
|
||||
run.status !== "waiting" ||
|
||||
!context ||
|
||||
gate?.status !== "open" ||
|
||||
gate.kind !== "implementation-context" ||
|
||||
gate.id !== context.gateId ||
|
||||
run.planArtifact?.revision !== context.planRevision ||
|
||||
context.runRevision > run.revision ||
|
||||
run.revision - context.runRevision > 1 ||
|
||||
(sessionId && context.activeParentSessionId !== sessionId) ||
|
||||
run.assignments.some(
|
||||
(assignment) =>
|
||||
assignment.status === "running" ||
|
||||
(assignment.status === "queued" && Boolean(assignment.executionToken)),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(context.status === "handoff-pending" &&
|
||||
context.strategy === "fresh-session" &&
|
||||
context.method === "fresh-session") ||
|
||||
(context.status === "normal-compact-pending" &&
|
||||
context.strategy === "smart-compact" &&
|
||||
context.method === "smart_compact" &&
|
||||
context.outcome === "unavailable")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import { openWorkflowDashboard } from "./dashboard.ts";
|
||||
|
||||
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
runId: "run-1",
|
||||
revision: 12,
|
||||
mode: "feature",
|
||||
status: "waiting",
|
||||
background: false,
|
||||
request: "Build search page",
|
||||
featureName: "search-page",
|
||||
parentSessionIds: ["session-1"],
|
||||
lease: {
|
||||
processId: 100,
|
||||
activeParentSessionId: "session-1",
|
||||
heartbeatAt: 1,
|
||||
},
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/workflow-search-page-run-1",
|
||||
branch: "feature/search-page",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
pinnedProfiles: [],
|
||||
preflightEligibility: [],
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["search"],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
stages: [
|
||||
{ id: "discover", kind: "discover", status: "completed" },
|
||||
{ id: "design", kind: "design", status: "waiting" },
|
||||
{ id: "prototype", kind: "prototype", status: "pending" },
|
||||
{ id: "implement", kind: "implement", status: "pending" },
|
||||
{ id: "verify", kind: "verify", status: "pending" },
|
||||
{ id: "review", kind: "review", status: "pending" },
|
||||
],
|
||||
gates: [
|
||||
{
|
||||
id: "gate-1",
|
||||
kind: "plan-approval",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
assignments: [],
|
||||
prototypeCandidates: [],
|
||||
prototypeCleanupCompleted: false,
|
||||
deliveries: [],
|
||||
commandReceipts: [],
|
||||
uiImpact: "minor",
|
||||
uiImpactEvidence: ["One visible field changes"],
|
||||
affectedUiSurfaces: ["Search page"],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function prototypeCandidate() {
|
||||
return {
|
||||
id: "candidate-1",
|
||||
revision: 2,
|
||||
assignmentId: "prototype-assignment-1",
|
||||
profileName: "frontend-prototyper",
|
||||
status: "succeeded" as const,
|
||||
path: "/repo/prototypes/candidate-1",
|
||||
route: "/search?variant=1",
|
||||
structuralRationale: "Compact drill-down flow",
|
||||
responsiveChecks: ["mobile", "desktop"],
|
||||
browserEvidence: ["validated in browser"],
|
||||
launchInstructions: "Run the prototype server.",
|
||||
visualDigest: "visual-digest",
|
||||
artifactPath: "/state/artifacts/candidate-1.md",
|
||||
retainedArtifacts: ["/state/artifacts/candidate-1.png"],
|
||||
};
|
||||
}
|
||||
|
||||
function view(workflow: WorkflowRun): WorkflowView {
|
||||
return {
|
||||
runId: workflow.runId,
|
||||
featureName: workflow.featureName,
|
||||
request: workflow.request,
|
||||
revision: workflow.revision,
|
||||
status: workflow.status,
|
||||
background: workflow.background,
|
||||
workspace: workflow.workspace,
|
||||
openGate: workflow.gates.find((gate) => gate.status === "open"),
|
||||
uiImpact: workflow.uiImpact,
|
||||
uiImpactEvidence: workflow.uiImpactEvidence,
|
||||
affectedUiSurfaces: workflow.affectedUiSurfaces,
|
||||
planArtifact: workflow.planArtifact,
|
||||
reportArtifact: workflow.reportArtifact,
|
||||
implementationContext: workflow.implementationContext,
|
||||
stages: workflow.stages,
|
||||
assignments: workflow.assignments,
|
||||
mutatorProfileNames: workflow.mutatorProfileNames,
|
||||
reviewCycles: workflow.reviewCycles,
|
||||
verificationCycles: workflow.verificationCycles,
|
||||
};
|
||||
}
|
||||
|
||||
function tuiContext(results: unknown[], sessionId = "session-1") {
|
||||
const notifications: string[] = [];
|
||||
const renders: string[][] = [];
|
||||
let customCalls = 0;
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
const unsupported = () => {
|
||||
throw new Error("built-in bounded dialog must not be called");
|
||||
};
|
||||
const context = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: {
|
||||
getSessionId: () => sessionId,
|
||||
},
|
||||
ui: {
|
||||
custom: async (
|
||||
factory: (
|
||||
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
|
||||
overlayTheme: typeof theme,
|
||||
keybindings: object,
|
||||
done: (value: unknown) => void,
|
||||
) => { render(width: number): string[] },
|
||||
) => {
|
||||
customCalls++;
|
||||
const component = factory(
|
||||
{
|
||||
requestRender() {},
|
||||
terminal: { columns: 100, rows: 24 },
|
||||
},
|
||||
theme,
|
||||
{},
|
||||
() => {},
|
||||
);
|
||||
renders.push(component.render(100));
|
||||
return results.shift();
|
||||
},
|
||||
select: unsupported,
|
||||
confirm: unsupported,
|
||||
input: unsupported,
|
||||
editor: unsupported,
|
||||
notify: (message: string) => notifications.push(message),
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
return { context, notifications, renders, customCalls: () => customCalls };
|
||||
}
|
||||
|
||||
function interactiveTuiContext(inputs: string[][], sessionId = "session-1") {
|
||||
let customCalls = 0;
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
const context = {
|
||||
hasUI: true,
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => sessionId },
|
||||
ui: {
|
||||
async custom(
|
||||
factory: (
|
||||
tui: { requestRender(): void; terminal: { columns: number; rows: number } },
|
||||
overlayTheme: typeof theme,
|
||||
keybindings: object,
|
||||
done: (value: unknown) => void,
|
||||
) => {
|
||||
render(width: number): string[];
|
||||
handleInput?(data: string): void;
|
||||
},
|
||||
) {
|
||||
customCalls++;
|
||||
let completed: unknown;
|
||||
const component = factory(
|
||||
{
|
||||
requestRender() {},
|
||||
terminal: { columns: 80, rows: 16 },
|
||||
},
|
||||
theme,
|
||||
{},
|
||||
(value) => { completed = value; },
|
||||
);
|
||||
component.render(56);
|
||||
for (const input of inputs.shift() ?? []) component.handleInput?.(input);
|
||||
return completed;
|
||||
},
|
||||
notify() {},
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
return { context, customCalls: () => customCalls };
|
||||
}
|
||||
|
||||
const pi = {
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
test("dashboard fresh-session action returns an immediate continuation result", async () => {
|
||||
const workflow = run({
|
||||
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
|
||||
gates: [
|
||||
{
|
||||
id: "context-gate",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
implementationContext: {
|
||||
gateId: "context-gate",
|
||||
runRevision: 12,
|
||||
planRevision: 3,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
});
|
||||
const tui = tuiContext(["run-1", "continue"]);
|
||||
const result = await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.deepEqual(result, { continueRunId: "run-1" });
|
||||
assert.equal(tui.customCalls(), 2);
|
||||
});
|
||||
|
||||
test("dashboard records Plan Approval using only custom TUI overlays", async () => {
|
||||
const workflow = run({
|
||||
planArtifact: {
|
||||
path: "/state/artifacts/plan.md",
|
||||
revision: 3,
|
||||
digest: "digest",
|
||||
},
|
||||
});
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"decide",
|
||||
"approve",
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return view(workflow);
|
||||
},
|
||||
}) as never,
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
const decision = commands[0];
|
||||
assert.equal(decision?.action, "decide");
|
||||
assert.deepEqual(
|
||||
decision?.action === "decide" ? decision.decision : undefined,
|
||||
{
|
||||
kind: "approve-plan",
|
||||
approved: true,
|
||||
acceptedUiImpact: "minor",
|
||||
},
|
||||
);
|
||||
assert.ok(tui.customCalls() >= 4);
|
||||
});
|
||||
|
||||
test("prototype reader Escape returns to candidate actions without accepting", async () => {
|
||||
const candidate = prototypeCandidate();
|
||||
const workflow = run({
|
||||
stages: run().stages.map((stage) =>
|
||||
stage.kind === "prototype"
|
||||
? { ...stage, status: "waiting" as const }
|
||||
: stage,
|
||||
),
|
||||
gates: [
|
||||
{
|
||||
id: "prototype-gate",
|
||||
kind: "prototype-acceptance",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
prototypeCandidates: [candidate],
|
||||
});
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"decide",
|
||||
{ kind: "candidate", candidate },
|
||||
"review",
|
||||
undefined,
|
||||
"back",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return view(workflow);
|
||||
},
|
||||
}) as never,
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.deepEqual(commands, []);
|
||||
assert.ok(
|
||||
tui.renders.filter((lines) => lines.join("\n").includes("Prototype 2"))
|
||||
.length >= 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("prototype acceptance requires the explicit candidate Accept action", async () => {
|
||||
const candidate = prototypeCandidate();
|
||||
const workflow = run({
|
||||
gates: [
|
||||
{
|
||||
id: "prototype-gate",
|
||||
kind: "prototype-acceptance",
|
||||
status: "open",
|
||||
revision: 12,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
prototypeCandidates: [candidate],
|
||||
});
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"decide",
|
||||
{ kind: "candidate", candidate },
|
||||
"review",
|
||||
undefined,
|
||||
"accept",
|
||||
true,
|
||||
"",
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return view(workflow);
|
||||
},
|
||||
}) as never,
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
const decision = commands[0];
|
||||
assert.deepEqual(
|
||||
decision?.action === "decide" ? decision.decision : undefined,
|
||||
{
|
||||
kind: "accept-prototype",
|
||||
candidateId: candidate.id,
|
||||
candidateRevision: candidate.revision,
|
||||
browserReviewed: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Escape from run detail returns one level to the run list", async () => {
|
||||
const tui = tuiContext(["run-1", undefined, undefined]);
|
||||
const result = await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [run()] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
assert.equal(tui.customCalls(), 3);
|
||||
});
|
||||
|
||||
test("dashboard handleInput drives selection and Escape back-stack navigation", async () => {
|
||||
const tui = interactiveTuiContext([["\r"], ["\x1b"], ["\x1b"]]);
|
||||
const result = await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [run()] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
assert.equal(tui.customCalls(), 3);
|
||||
});
|
||||
|
||||
test("dashboard duplicate labels retain their canonical run selection IDs", async () => {
|
||||
const workflows = [
|
||||
run({ runId: "12345678-aaaa", featureName: "search-page" }),
|
||||
run({ runId: "12345678-bbbb", featureName: "search-page" }),
|
||||
];
|
||||
const tui = tuiContext(["12345678-bbbb", undefined, undefined]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => workflows } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
const list = tui.renders[0]?.join("\n") ?? "";
|
||||
assert.match(list, /search-page · 12345678-a/);
|
||||
assert.match(list, /search-page · 12345678-b/);
|
||||
assert.match(tui.renders[1]?.join("\n") ?? "", /search-page · 12345678-b/);
|
||||
});
|
||||
|
||||
test("dashboard disambiguates duplicate labels across the full run list", async () => {
|
||||
const workflows = Array.from({ length: 30 }, (_, index) =>
|
||||
run({
|
||||
runId:
|
||||
index === 0
|
||||
? "12345678-aaaa"
|
||||
: index === 29
|
||||
? "12345678-bbbb"
|
||||
: `run-${index}`,
|
||||
featureName:
|
||||
index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
|
||||
}),
|
||||
);
|
||||
const tui = tuiContext([undefined]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => workflows } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
const rendered = tui.renders[0]?.join("\n") ?? "";
|
||||
assert.match(rendered, /shared-label · 12345678-a/);
|
||||
assert.match(rendered, /1-12 of 30/);
|
||||
assert.doesNotMatch(rendered, /shared-label\s+WAITING/);
|
||||
});
|
||||
|
||||
test("historical parent sessions receive inspection-only dashboard actions", async () => {
|
||||
const workflow = run({
|
||||
parentSessionIds: ["session-1", "session-2"],
|
||||
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
|
||||
});
|
||||
const tui = tuiContext(["run-1", undefined, undefined], "session-2");
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
const actions = tui.renders[1]?.join("\n") ?? "";
|
||||
assert.match(actions, /Review verified plan/);
|
||||
assert.doesNotMatch(actions, /Record Gate Decision/);
|
||||
assert.doesNotMatch(actions, /Continue context transition/);
|
||||
});
|
||||
|
||||
test("dashboard verifies and opens retained report content", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-dashboard-"));
|
||||
try {
|
||||
const reportPath = path.join(directory, "report.md");
|
||||
const reportContents = "# Completed report\n\nVerified.";
|
||||
await writeFile(reportPath, reportContents, "utf8");
|
||||
const workflow = run({
|
||||
status: "completed",
|
||||
gates: [],
|
||||
reportArtifact: {
|
||||
path: reportPath,
|
||||
digest: createHash("sha256").update(reportContents).digest("hex"),
|
||||
revision: 12,
|
||||
},
|
||||
});
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"report",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.ok(
|
||||
tui.renders.some((lines) =>
|
||||
lines.join("\n").includes("Completed report"),
|
||||
),
|
||||
);
|
||||
assert.ok(tui.renders.some((lines) => lines.join("\n").includes("Verified.")));
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("retained artifact reader Escape returns through artifact and candidate selection", async () => {
|
||||
const candidate = prototypeCandidate();
|
||||
const workflow = run({ prototypeCandidates: [candidate] });
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"prototypes",
|
||||
candidate,
|
||||
candidate.artifactPath,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.ok(
|
||||
tui.renders.filter((lines) => lines.join("\n").includes("Retained artifact"))
|
||||
.length >= 2,
|
||||
);
|
||||
assert.ok(
|
||||
tui.renders.filter((lines) => lines.join("\n").includes("Prototype evidence"))
|
||||
.length >= 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard verifies and opens Assignment transcripts", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-transcript-"));
|
||||
try {
|
||||
const transcriptPath = path.join(directory, "assignment.json");
|
||||
const transcriptContents = '[{"role":"assistant","text":"verified"}]\n';
|
||||
await writeFile(transcriptPath, transcriptContents, "utf8");
|
||||
const workflow = run({
|
||||
assignments: [
|
||||
{
|
||||
id: "assignment-1",
|
||||
stageId: "review",
|
||||
role: "review",
|
||||
profileName: "independent-reviewer",
|
||||
profileDigest: "digest",
|
||||
selection: {
|
||||
eligibleProfiles: [],
|
||||
selectedProfileName: "independent-reviewer",
|
||||
rationale: "Review",
|
||||
},
|
||||
status: "succeeded",
|
||||
mutates: false,
|
||||
ownedPaths: [],
|
||||
transcriptArtifact: {
|
||||
path: transcriptPath,
|
||||
digest: createHash("sha256")
|
||||
.update(transcriptContents)
|
||||
.digest("hex"),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const assignment = workflow.assignments[0];
|
||||
assert.ok(assignment);
|
||||
const tui = tuiContext([
|
||||
"run-1",
|
||||
"transcripts",
|
||||
assignment,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [workflow] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
);
|
||||
assert.ok(
|
||||
tui.renders.some((lines) =>
|
||||
lines.join("\n").includes('"text":"verified"'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
tui.renders.filter((lines) =>
|
||||
lines.join("\n").includes("Assignment transcripts"),
|
||||
).length >= 2,
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RPC and print dashboards stay linear and never call custom", async () => {
|
||||
for (const mode of ["rpc", "print", "json"] as const) {
|
||||
const notifications: string[] = [];
|
||||
const ctx = {
|
||||
hasUI: mode === "rpc",
|
||||
mode,
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => {
|
||||
throw new Error("custom must not be called outside TUI mode");
|
||||
},
|
||||
notify: (message: string) => notifications.push(message),
|
||||
},
|
||||
} as unknown as ExtensionCommandContext;
|
||||
assert.deepEqual(
|
||||
await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [run()] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
{},
|
||||
);
|
||||
if (mode === "rpc") assert.match(notifications[0] ?? "", /search-page/);
|
||||
}
|
||||
});
|
||||
|
||||
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
|
||||
const tui = tuiContext([]);
|
||||
const result = await openWorkflowDashboard(
|
||||
pi,
|
||||
{
|
||||
store: { listRuns: async () => [run()] } as never,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
},
|
||||
tui.context,
|
||||
"missing-run-id",
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
assert.deepEqual(tui.notifications, [
|
||||
"Workflow Run missing-run-id was not found for this session.",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,518 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { isValidContextContinuation } from "./continuation.ts";
|
||||
import type {
|
||||
GateDecision,
|
||||
PrototypeCandidate,
|
||||
WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import {
|
||||
executeHostDecision,
|
||||
type ContextTransitionRuntime,
|
||||
} from "./context-transition.ts";
|
||||
import { deriveRunLabels } from "./run-label.ts";
|
||||
import {
|
||||
readVerifiedPlanArtifact,
|
||||
readVerifiedReportArtifact,
|
||||
readVerifiedTranscriptArtifact,
|
||||
} from "./store.ts";
|
||||
import {
|
||||
workflowConfirm,
|
||||
workflowEditor,
|
||||
workflowInput,
|
||||
workflowPlanReader,
|
||||
workflowSelect,
|
||||
} from "./tui.ts";
|
||||
|
||||
const CORE_STAGES = [
|
||||
"discover",
|
||||
"design",
|
||||
"prototype",
|
||||
"implement",
|
||||
"verify",
|
||||
"review",
|
||||
] as const;
|
||||
|
||||
function activeStage(run: WorkflowRun) {
|
||||
return (
|
||||
[...run.stages]
|
||||
.reverse()
|
||||
.find((stage) => stage.status === "running" || stage.status === "waiting")
|
||||
?.kind ?? run.status
|
||||
);
|
||||
}
|
||||
|
||||
function selectedGate(run: WorkflowRun) {
|
||||
return [...run.gates].reverse().find((gate) => gate.status === "open");
|
||||
}
|
||||
|
||||
function stageSummary(run: WorkflowRun) {
|
||||
return run.stages
|
||||
.filter((stage) => CORE_STAGES.includes(stage.kind as (typeof CORE_STAGES)[number]))
|
||||
.map((stage) => {
|
||||
const marker =
|
||||
stage.status === "completed"
|
||||
? "✓"
|
||||
: stage.status === "failed"
|
||||
? "×"
|
||||
: stage.status === "running" || stage.status === "waiting"
|
||||
? "●"
|
||||
: "·";
|
||||
return `${marker} ${stage.kind}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function runSummary(run: WorkflowRun, label: string) {
|
||||
const gate = selectedGate(run);
|
||||
return [
|
||||
`${label} · ${run.status} · revision ${run.revision}`,
|
||||
`Stage: ${activeStage(run)}`,
|
||||
`Progress: ${stageSummary(run)}`,
|
||||
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
|
||||
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
|
||||
...(run.workspace.root ? [`Worktree: ${run.workspace.root}`] : []),
|
||||
...(run.planArtifact ? [`Plan: ${run.planArtifact.path}`] : []),
|
||||
...(run.reportArtifact ? [`Report: ${run.reportArtifact.path}`] : []),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function idempotencyKey(run: WorkflowRun, decision: GateDecision) {
|
||||
return `dashboard-${run.runId}-${run.revision}-${decision.kind}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
async function decide(
|
||||
pi: Pick<ExtensionAPI, "getActiveTools">,
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionContext,
|
||||
run: WorkflowRun,
|
||||
decision: GateDecision,
|
||||
) {
|
||||
const gate = selectedGate(run);
|
||||
if (!gate) throw new Error("Workflow Run has no open Gate");
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
return executeHostDecision(pi, engine, {
|
||||
action: "decide",
|
||||
runId: run.runId,
|
||||
gateId: gate.id,
|
||||
expectedRevision: run.revision,
|
||||
decision,
|
||||
idempotencyKey: idempotencyKey(run, decision),
|
||||
});
|
||||
}
|
||||
|
||||
async function questionDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const payload = selectedGate(run)?.payload;
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const questions = (payload as { questions?: unknown }).questions;
|
||||
if (!Array.isArray(questions)) return undefined;
|
||||
const answers: Record<string, string> = {};
|
||||
for (const item of questions) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const id = (item as { id?: unknown }).id;
|
||||
const prompt = (item as { prompt?: unknown }).prompt;
|
||||
if (typeof id !== "string" || typeof prompt !== "string") continue;
|
||||
const answer = await workflowInput(ctx, "Required question", prompt);
|
||||
if (!answer?.trim()) return undefined;
|
||||
answers[id] = answer.trim();
|
||||
}
|
||||
return { kind: "answer-questions" as const, answers };
|
||||
}
|
||||
|
||||
async function planDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
if (!run.planArtifact || !run.uiImpact) return undefined;
|
||||
while (true) {
|
||||
const action = await workflowSelect(ctx, "Plan Approval", [
|
||||
{ value: "inspect" as const, label: "Review verified plan" },
|
||||
{
|
||||
value: "approve" as const,
|
||||
label: `Approve plan · UI impact ${run.uiImpact}`,
|
||||
},
|
||||
{ value: "revise" as const, label: "Request revision" },
|
||||
]);
|
||||
if (!action) return undefined;
|
||||
if (action === "inspect") {
|
||||
const plan = await readVerifiedPlanArtifact(run.planArtifact);
|
||||
await workflowPlanReader(ctx, run.featureName ?? run.runId, plan);
|
||||
continue;
|
||||
}
|
||||
if (action === "revise") {
|
||||
const feedback = await workflowEditor(
|
||||
ctx,
|
||||
"Plan revision feedback",
|
||||
"Describe the required plan changes.",
|
||||
);
|
||||
return feedback?.trim()
|
||||
? { kind: "revise-plan" as const, feedback: feedback.trim() }
|
||||
: undefined;
|
||||
}
|
||||
const approved = await workflowConfirm(
|
||||
ctx,
|
||||
"Approve plan",
|
||||
`Approve plan revision ${run.planArtifact.revision} and explicitly accept UI impact ${run.uiImpact}?`,
|
||||
);
|
||||
return approved
|
||||
? {
|
||||
kind: "approve-plan" as const,
|
||||
approved: true as const,
|
||||
acceptedUiImpact: run.uiImpact,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function prototypeDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const candidates = run.prototypeCandidates.slice(-3);
|
||||
while (true) {
|
||||
const action = await workflowSelect<
|
||||
| { kind: "candidate"; candidate: PrototypeCandidate }
|
||||
| { kind: "refine" }
|
||||
| { kind: "abort" }
|
||||
>(ctx, "Prototype Acceptance", [
|
||||
...candidates.map((candidate, index) => ({
|
||||
value: { kind: "candidate" as const, candidate },
|
||||
label: `${index + 1}. ${candidate.structuralRationale}`,
|
||||
description: candidate.route,
|
||||
})),
|
||||
{ value: { kind: "refine" as const }, label: "Request refinement" },
|
||||
{ value: { kind: "abort" as const }, label: "Abort run" },
|
||||
]);
|
||||
if (!action) return undefined;
|
||||
if (action.kind === "refine") {
|
||||
const feedback = await workflowEditor(
|
||||
ctx,
|
||||
"Prototype refinement feedback",
|
||||
"Describe what the next candidates must change.",
|
||||
);
|
||||
return feedback?.trim()
|
||||
? { kind: "refine-prototypes" as const, feedback: feedback.trim() }
|
||||
: undefined;
|
||||
}
|
||||
if (action.kind === "abort") {
|
||||
return (await workflowConfirm(
|
||||
ctx,
|
||||
"Abort Workflow Run",
|
||||
"Abort this run and preserve its current evidence?",
|
||||
))
|
||||
? ("abort" as const)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const candidate = action.candidate;
|
||||
while (true) {
|
||||
const candidateAction = await workflowSelect(
|
||||
ctx,
|
||||
`Prototype ${candidate.revision}`,
|
||||
[
|
||||
{ value: "review" as const, label: "Review candidate evidence" },
|
||||
{ value: "accept" as const, label: "Accept this candidate" },
|
||||
{ value: "back" as const, label: "Back to candidates" },
|
||||
],
|
||||
);
|
||||
if (!candidateAction || candidateAction === "back") break;
|
||||
if (candidateAction === "review") {
|
||||
await workflowPlanReader(
|
||||
ctx,
|
||||
`prototype ${candidate.revision}`,
|
||||
[
|
||||
candidate.launchInstructions,
|
||||
`Route: ${candidate.route}`,
|
||||
`Evidence: ${candidate.retainedArtifacts.join(", ")}`,
|
||||
].join("\n\n"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const browserReviewed = await workflowConfirm(
|
||||
ctx,
|
||||
"Accept prototype",
|
||||
"Confirm you reviewed this exact candidate in the browser or application at mobile and desktop sizes.",
|
||||
);
|
||||
if (!browserReviewed) continue;
|
||||
const feedback = await workflowInput(
|
||||
ctx,
|
||||
"Acceptance feedback",
|
||||
"Optional feedback. Submit an empty value for none.",
|
||||
);
|
||||
return {
|
||||
kind: "accept-prototype" as const,
|
||||
candidateId: candidate.id,
|
||||
candidateRevision: candidate.revision,
|
||||
browserReviewed: true as const,
|
||||
...(feedback?.trim() ? { feedback: feedback.trim() } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function implementationContextDecision(ctx: ExtensionContext) {
|
||||
const strategy = await workflowSelect(ctx, "Implementation Context", [
|
||||
{
|
||||
value: "fresh-session" as const,
|
||||
label: "Fresh plan-seeded session",
|
||||
description: "Replace this session and continue immediately.",
|
||||
},
|
||||
{
|
||||
value: "smart-compact" as const,
|
||||
label: "Compact current session",
|
||||
},
|
||||
{
|
||||
value: "current-session" as const,
|
||||
label: "Keep current context unchanged",
|
||||
},
|
||||
]);
|
||||
return strategy
|
||||
? { kind: "select-implementation-context" as const, strategy }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function recoveryDecision(ctx: ExtensionContext) {
|
||||
const resolution = await workflowSelect(ctx, "Recovery", [
|
||||
{ value: "retry" as const, label: "Retry" },
|
||||
{ value: "inspect" as const, label: "Inspect" },
|
||||
{ value: "abort" as const, label: "Abort" },
|
||||
]);
|
||||
return resolution
|
||||
? { kind: "recover" as const, resolution }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function gateDecision(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const gate = selectedGate(run);
|
||||
if (!gate) return undefined;
|
||||
if (gate.kind === "questions") return questionDecision(ctx, run);
|
||||
if (gate.kind === "plan-approval") return planDecision(ctx, run);
|
||||
if (gate.kind === "prototype-acceptance") return prototypeDecision(ctx, run);
|
||||
if (gate.kind === "implementation-context") {
|
||||
return implementationContextDecision(ctx);
|
||||
}
|
||||
return recoveryDecision(ctx);
|
||||
}
|
||||
|
||||
function validContinuation(run: WorkflowRun, sessionId?: string) {
|
||||
return isValidContextContinuation(
|
||||
{ ...run, openGate: selectedGate(run) },
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
function isActiveParent(run: WorkflowRun, sessionId?: string) {
|
||||
return Boolean(sessionId && run.lease?.activeParentSessionId === sessionId);
|
||||
}
|
||||
|
||||
async function browsePrototypeArtifacts(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
while (true) {
|
||||
const candidate = await workflowSelect(
|
||||
ctx,
|
||||
"Prototype evidence",
|
||||
run.prototypeCandidates.map((entry, index) => ({
|
||||
value: entry,
|
||||
label: `${index + 1}. ${entry.structuralRationale}`,
|
||||
description: entry.route,
|
||||
})),
|
||||
);
|
||||
if (!candidate) return;
|
||||
while (true) {
|
||||
const artifact = await workflowSelect(
|
||||
ctx,
|
||||
"Retained artifact",
|
||||
[candidate.artifactPath, ...candidate.retainedArtifacts].map((entry) => ({
|
||||
value: entry,
|
||||
label: entry,
|
||||
})),
|
||||
);
|
||||
if (!artifact) break;
|
||||
if (artifact.endsWith(".json")) {
|
||||
await workflowPlanReader(
|
||||
ctx,
|
||||
"prototype evidence",
|
||||
await readFile(artifact, "utf8"),
|
||||
);
|
||||
} else {
|
||||
await workflowPlanReader(
|
||||
ctx,
|
||||
"prototype evidence",
|
||||
`${candidate.launchInstructions}\n\nRoute: ${candidate.route}\n\nArtifact: ${artifact}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function browseAssignmentTranscripts(ctx: ExtensionContext, run: WorkflowRun) {
|
||||
const assignments = run.assignments.filter(
|
||||
(assignment) => assignment.transcriptArtifact,
|
||||
);
|
||||
while (true) {
|
||||
const assignment = await workflowSelect(
|
||||
ctx,
|
||||
"Assignment transcripts",
|
||||
assignments.map((entry, index) => ({
|
||||
value: entry,
|
||||
label: `${index + 1}. ${entry.role}/${entry.profileName}`,
|
||||
description: entry.status,
|
||||
})),
|
||||
);
|
||||
if (!assignment?.transcriptArtifact) return;
|
||||
const transcript = await readVerifiedTranscriptArtifact(
|
||||
assignment.transcriptArtifact,
|
||||
);
|
||||
await workflowPlanReader(ctx, `${assignment.role} transcript`, transcript);
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardActionResult =
|
||||
| { kind: "back" }
|
||||
| { kind: "continue"; runId: string }
|
||||
| { kind: "closed" };
|
||||
|
||||
async function actionSheet(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionCommandContext,
|
||||
initialRun: WorkflowRun,
|
||||
label: string,
|
||||
): Promise<DashboardActionResult> {
|
||||
let run = initialRun;
|
||||
while (true) {
|
||||
const actions = [
|
||||
{ value: "inspect" as const, label: "Inspect run", description: runSummary(run, label) },
|
||||
...(selectedGate(run) && isActiveParent(run, ctx.sessionManager.getSessionId())
|
||||
? [{ value: "decide" as const, label: "Record Gate Decision", description: selectedGate(run)?.kind }]
|
||||
: []),
|
||||
...(validContinuation(run, ctx.sessionManager.getSessionId())
|
||||
? [{ value: "continue" as const, label: "Continue context transition", description: "Enter the selected implementation context now." }]
|
||||
: []),
|
||||
...(run.planArtifact ? [{ value: "plan" as const, label: "Review verified plan" }] : []),
|
||||
...(run.reportArtifact ? [{ value: "report" as const, label: "Open report" }] : []),
|
||||
...(run.prototypeCandidates.length > 0
|
||||
? [{ value: "prototypes" as const, label: "Browse prototype evidence" }]
|
||||
: []),
|
||||
...(run.assignments.some((assignment) => assignment.transcriptArtifact)
|
||||
? [{ value: "transcripts" as const, label: "Browse Assignment transcripts" }]
|
||||
: []),
|
||||
...(run.workspace.root ? [{ value: "worktree" as const, label: "Show worktree path" }] : []),
|
||||
];
|
||||
const action = await workflowSelect(ctx, label, actions);
|
||||
if (!action) return { kind: "back" };
|
||||
if (action === "continue") return { kind: "continue", runId: run.runId };
|
||||
if (action === "inspect") {
|
||||
await workflowPlanReader(ctx, label, runSummary(run, label));
|
||||
} else if (action === "plan") {
|
||||
await workflowPlanReader(
|
||||
ctx,
|
||||
label,
|
||||
await readVerifiedPlanArtifact(run.planArtifact!),
|
||||
);
|
||||
} else if (action === "report") {
|
||||
await workflowPlanReader(
|
||||
ctx,
|
||||
`${label} report`,
|
||||
await readVerifiedReportArtifact(run.reportArtifact!),
|
||||
);
|
||||
} else if (action === "prototypes") {
|
||||
await browsePrototypeArtifacts(ctx, run);
|
||||
} else if (action === "transcripts") {
|
||||
await browseAssignmentTranscripts(ctx, run);
|
||||
} else if (action === "worktree") {
|
||||
await workflowPlanReader(ctx, "worktree", run.workspace.root!);
|
||||
} else if (action === "decide") {
|
||||
const decision = await gateDecision(ctx, run);
|
||||
if (decision === "abort") {
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
await engine.execute({
|
||||
action: "abort",
|
||||
runId: run.runId,
|
||||
expectedRevision: run.revision,
|
||||
idempotencyKey: `dashboard-abort-${run.runId}-${run.revision}-${randomUUID()}`,
|
||||
});
|
||||
} else if (decision) {
|
||||
const result = await decide(pi, runtime, ctx, run, decision);
|
||||
if (
|
||||
decision.kind === "select-implementation-context" &&
|
||||
decision.strategy === "smart-compact"
|
||||
) {
|
||||
ctx.ui.notify(
|
||||
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through workflow.`,
|
||||
"info",
|
||||
);
|
||||
} else if (
|
||||
decision.kind === "select-implementation-context" &&
|
||||
decision.strategy === "fresh-session"
|
||||
) {
|
||||
return { kind: "continue", runId: run.runId };
|
||||
} else {
|
||||
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
|
||||
}
|
||||
}
|
||||
const refreshed = (await runtime.store.listRuns()).find((candidate) => candidate.runId === run.runId);
|
||||
if (!refreshed || ["completed", "failed", "aborted"].includes(refreshed.status)) {
|
||||
return { kind: "closed" };
|
||||
}
|
||||
run = refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardResult {
|
||||
continueRunId?: string;
|
||||
}
|
||||
|
||||
export async function openWorkflowDashboard(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionCommandContext,
|
||||
initialRunId?: string,
|
||||
): Promise<DashboardResult> {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
const runs = (await runtime.store.listRuns())
|
||||
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
|
||||
.sort((left, right) => {
|
||||
const leftTime = Math.max(left.lease?.heartbeatAt ?? 0, ...left.gates.map((gate) => gate.openedAt));
|
||||
const rightTime = Math.max(right.lease?.heartbeatAt ?? 0, ...right.gates.map((gate) => gate.openedAt));
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
|
||||
ctx.ui.notify(`Workflow Run ${initialRunId} was not found for this session.`, "error");
|
||||
return {};
|
||||
}
|
||||
const labels = deriveRunLabels(runs);
|
||||
if (!ctx.hasUI || ctx.mode !== "tui") {
|
||||
const visibleRuns = runs.slice(0, 20);
|
||||
const omitted = runs.length - visibleRuns.length;
|
||||
ctx.ui.notify(
|
||||
runs.length > 0
|
||||
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""}`
|
||||
: "No Workflow Runs.",
|
||||
"info",
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
let selectedRunId = initialRunId;
|
||||
while (true) {
|
||||
if (!selectedRunId) {
|
||||
selectedRunId = await workflowSelect(
|
||||
ctx,
|
||||
"workflow",
|
||||
runs.map((run, index) => ({
|
||||
value: run.runId,
|
||||
label: `${labels[index]} ${run.status.toUpperCase()} ${String(activeStage(run))}`,
|
||||
description: selectedGate(run)?.kind,
|
||||
})),
|
||||
);
|
||||
if (!selectedRunId) return {};
|
||||
}
|
||||
const index = runs.findIndex((candidate) => candidate.runId === selectedRunId);
|
||||
const run = runs[index];
|
||||
if (!run) return {};
|
||||
const result = await actionSheet(pi, runtime, ctx, run, labels[index]!);
|
||||
if (result.kind === "continue") return { continueRunId: result.runId };
|
||||
if (result.kind === "closed") return {};
|
||||
selectedRunId = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
export const PROFILE_ROLES = [
|
||||
"discover",
|
||||
"design",
|
||||
"prototype",
|
||||
"implement",
|
||||
"verify",
|
||||
"review",
|
||||
] as const;
|
||||
|
||||
export type ProfileRole = (typeof PROFILE_ROLES)[number];
|
||||
export type AssignmentStageKind = ProfileRole;
|
||||
export type WorkflowStageKind =
|
||||
| "initialize"
|
||||
| AssignmentStageKind
|
||||
| "remediate"
|
||||
| "complete"
|
||||
| `custom:${string}`;
|
||||
|
||||
export const REASONING_EFFORTS = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
||||
|
||||
export interface ProfileSelection {
|
||||
stages: AssignmentStageKind[];
|
||||
anyTags: string[];
|
||||
allTags: string[];
|
||||
pathGlobs: string[];
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface AgentProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: ProfileRole[];
|
||||
model?: string;
|
||||
provider?: string;
|
||||
effort?: ReasoningEffort;
|
||||
tools?: string[];
|
||||
selection: ProfileSelection;
|
||||
instructions: string;
|
||||
source:
|
||||
| "bundled"
|
||||
| "project"
|
||||
| "standard-package"
|
||||
| "standard-global"
|
||||
| "standard-project";
|
||||
sourcePath: string;
|
||||
resolvedPath: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface ProfileDiagnostic {
|
||||
sourcePath: string;
|
||||
severity: "warning" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ProfileCatalog {
|
||||
profiles: AgentProfile[];
|
||||
diagnostics: ProfileDiagnostic[];
|
||||
}
|
||||
|
||||
export interface WorkflowFacts {
|
||||
stage: AssignmentStageKind;
|
||||
taskKind: "feature" | "analysis";
|
||||
requestTerms: string[];
|
||||
candidatePaths: string[];
|
||||
languages: string[];
|
||||
tags: string[];
|
||||
riskTags: string[];
|
||||
}
|
||||
|
||||
export interface EligibilityResult {
|
||||
profile: AgentProfile;
|
||||
eligible: boolean;
|
||||
reasons: string[];
|
||||
specificity: number;
|
||||
}
|
||||
|
||||
export type RunStatus =
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "aborted";
|
||||
export type AssignmentStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "interrupted";
|
||||
export type StageStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "completed"
|
||||
| "failed";
|
||||
export type GateKind =
|
||||
| "questions"
|
||||
| "plan-approval"
|
||||
| "prototype-acceptance"
|
||||
| "implementation-context"
|
||||
| "recovery";
|
||||
export type UiImpact = "none" | "minor" | "non-trivial";
|
||||
|
||||
export type GateDecision =
|
||||
| { kind: "answer-questions"; answers: Record<string, string> }
|
||||
| {
|
||||
kind: "approve-plan";
|
||||
approved: true;
|
||||
acceptedUiImpact: UiImpact;
|
||||
comment?: string;
|
||||
}
|
||||
| { kind: "revise-plan"; feedback: string }
|
||||
| {
|
||||
kind: "accept-prototype";
|
||||
candidateId: string;
|
||||
candidateRevision: number;
|
||||
browserReviewed: true;
|
||||
feedback?: string;
|
||||
}
|
||||
| { kind: "refine-prototypes"; feedback: string }
|
||||
| {
|
||||
kind: "select-implementation-context";
|
||||
strategy:
|
||||
| "fresh-session"
|
||||
| "smart-compact"
|
||||
| "current-session";
|
||||
}
|
||||
| {
|
||||
kind: "report-context-preparation";
|
||||
preparationId: string;
|
||||
planRevision: number;
|
||||
method: "smart_compact";
|
||||
outcome: "unavailable" | "succeeded" | "no-op";
|
||||
}
|
||||
| {
|
||||
kind: "report-context-preparation";
|
||||
preparationId: string;
|
||||
planRevision: number;
|
||||
method: "smart_compact";
|
||||
outcome: "failed";
|
||||
error: string;
|
||||
}
|
||||
| { kind: "recover"; resolution: "retry" | "inspect" | "abort" };
|
||||
|
||||
export interface RunLease {
|
||||
processId: number;
|
||||
activeParentSessionId: string;
|
||||
heartbeatAt: number;
|
||||
worktreeIdentity?: string;
|
||||
}
|
||||
|
||||
export interface SourceChangeEvidence {
|
||||
path: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface WorkflowWorkspace {
|
||||
state: "not-required" | "creating" | "ready" | "failed";
|
||||
sourceRoot: string;
|
||||
root?: string;
|
||||
branch?: string;
|
||||
baseHead?: string;
|
||||
sourceChanges?: SourceChangeEvidence[];
|
||||
sourceChangesOmitted?: number;
|
||||
dirtySourceAllowed?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStage {
|
||||
id: string;
|
||||
kind: WorkflowStageKind;
|
||||
status: StageStatus;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
output?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkflowGate {
|
||||
id: string;
|
||||
kind: GateKind;
|
||||
status: "open" | "satisfied" | "rejected";
|
||||
revision: number;
|
||||
openedAt: number;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkflowDecision {
|
||||
gateId: string;
|
||||
revision: number;
|
||||
idempotencyKey: string;
|
||||
decidedAt: number;
|
||||
value: GateDecision;
|
||||
}
|
||||
|
||||
export interface ProfileSelectionEvidence {
|
||||
profileName: string;
|
||||
profileDigest: string;
|
||||
eligible: boolean;
|
||||
reasons: string[];
|
||||
specificity: number;
|
||||
}
|
||||
|
||||
export interface AssignmentSelection {
|
||||
eligibleProfiles: ProfileSelectionEvidence[];
|
||||
selectedProfileName: string;
|
||||
rationale: string;
|
||||
}
|
||||
|
||||
export interface StageEligibilityEvidence {
|
||||
role: ProfileRole;
|
||||
candidates: ProfileSelectionEvidence[];
|
||||
}
|
||||
|
||||
export interface AssignmentTranscriptArtifact {
|
||||
path: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface AssignmentGitState {
|
||||
root: string;
|
||||
branch: string;
|
||||
head: string;
|
||||
commonDirectory: string;
|
||||
gitDirectory: string;
|
||||
worktreeListDigest: string;
|
||||
worktreeMetadataDigest: string;
|
||||
status: string;
|
||||
changedPaths: string[];
|
||||
pathStatuses: Record<string, string>;
|
||||
pathDigests: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface VerificationCommand {
|
||||
id: string;
|
||||
executable: string;
|
||||
args: string[];
|
||||
ownedPaths: string[];
|
||||
kind: "test" | "typecheck" | "lint" | "build" | "browser" | "generation";
|
||||
status:
|
||||
| "pending"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "interrupted"
|
||||
| "superseded";
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VerificationEvidence {
|
||||
commands: VerificationCommand[];
|
||||
focusedBehavioralTests: boolean;
|
||||
verifierSucceeded: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewAxis {
|
||||
verdict: "approved" | "changes_requested";
|
||||
findings: Array<{ severity: string; message: string; path?: string }>;
|
||||
}
|
||||
|
||||
export interface ReviewVerdict {
|
||||
specification: ReviewAxis;
|
||||
standards: ReviewAxis;
|
||||
}
|
||||
|
||||
export interface WorkflowAssignment {
|
||||
id: string;
|
||||
stageId: string;
|
||||
role: ProfileRole | "remediate";
|
||||
profileName: string;
|
||||
profileDigest: string;
|
||||
selection: AssignmentSelection;
|
||||
status: AssignmentStatus;
|
||||
mutates: boolean;
|
||||
ownedPaths: string[];
|
||||
scriptRequestId?: number;
|
||||
scriptRequestFingerprint?: string;
|
||||
executionToken?: string;
|
||||
childSessionId?: string;
|
||||
effectiveProvider?: string;
|
||||
effectiveModel?: string;
|
||||
effectiveEffort?: string;
|
||||
contextCapacity?: number;
|
||||
transcriptArtifact?: AssignmentTranscriptArtifact;
|
||||
gitBefore?: AssignmentGitState;
|
||||
gitAfter?: AssignmentGitState;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PrototypeCandidate {
|
||||
id: string;
|
||||
revision: number;
|
||||
assignmentId: string;
|
||||
profileName: string;
|
||||
status: "succeeded";
|
||||
path: string;
|
||||
route: string;
|
||||
structuralRationale: string;
|
||||
responsiveChecks: string[];
|
||||
browserEvidence: string[];
|
||||
launchInstructions: string;
|
||||
visualDigest: string;
|
||||
artifactPath: string;
|
||||
retainedArtifacts: string[];
|
||||
}
|
||||
|
||||
export interface DeliveryRecord {
|
||||
key: string;
|
||||
state: "pending" | "sent";
|
||||
createdAt: number;
|
||||
sentAt?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowReportArtifact {
|
||||
path: string;
|
||||
digest: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface PlanArtifact {
|
||||
path: string;
|
||||
revision: number;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface ImplementationContextState {
|
||||
gateId: string;
|
||||
runRevision: number;
|
||||
planRevision: number;
|
||||
preparationId: string;
|
||||
activeParentSessionId: string;
|
||||
sourceParentSessionId?: string;
|
||||
sourceSessionFile?: string;
|
||||
targetSessionFile?: string;
|
||||
claimedAt?: number;
|
||||
intentCreatedAt?: number;
|
||||
intentExpiresAt?: number;
|
||||
strategy?: "fresh-session" | "smart-compact" | "current-session";
|
||||
status:
|
||||
| "waiting"
|
||||
| "handoff-pending"
|
||||
| "claimed-target-awaiting-withSession"
|
||||
| "smart-compact-pending"
|
||||
| "normal-compact-pending"
|
||||
| "normal-compact-running"
|
||||
| "ready"
|
||||
| "failed";
|
||||
method?:
|
||||
| "fresh-session"
|
||||
| "smart_compact"
|
||||
| "normal-compact"
|
||||
| "unchanged";
|
||||
outcome?: "unavailable" | "succeeded" | "no-op" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowError {
|
||||
code: string;
|
||||
message: string;
|
||||
stageId?: string;
|
||||
assignmentId?: string;
|
||||
}
|
||||
|
||||
export type CommandContinuation =
|
||||
| "none"
|
||||
| "design"
|
||||
| "prototype"
|
||||
| "implementation"
|
||||
| "recovery";
|
||||
|
||||
export interface CommandReceipt {
|
||||
key: string;
|
||||
action: "decide" | "abort";
|
||||
fingerprint: string;
|
||||
status: "pending" | "completed";
|
||||
acceptedRevision: number;
|
||||
continuation?: CommandContinuation;
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export type FeatureScriptInsertionPoint =
|
||||
| "before-design"
|
||||
| "after-design"
|
||||
| "before-review";
|
||||
|
||||
export interface WorkflowScriptState {
|
||||
source: string;
|
||||
args?: unknown;
|
||||
insertionPoint?: FeatureScriptInsertionPoint;
|
||||
activeToolNames: string[];
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
stages: string[];
|
||||
executed: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowRun {
|
||||
runId: string;
|
||||
revision: number;
|
||||
mode: "feature" | "analysis";
|
||||
script?: WorkflowScriptState;
|
||||
status: RunStatus;
|
||||
background: boolean;
|
||||
request: string;
|
||||
featureName?: string;
|
||||
parentSessionIds: string[];
|
||||
planArtifact?: PlanArtifact;
|
||||
reportArtifact?: WorkflowReportArtifact;
|
||||
implementationContext?: ImplementationContextState;
|
||||
lease?: RunLease;
|
||||
workspace: WorkflowWorkspace;
|
||||
effectiveConfig: WorkflowConfig;
|
||||
pinnedProfiles: AgentProfile[];
|
||||
preflightEligibility: StageEligibilityEvidence[];
|
||||
facts: WorkflowFacts;
|
||||
stages: WorkflowStage[];
|
||||
gates: WorkflowGate[];
|
||||
decisions: WorkflowDecision[];
|
||||
assignments: WorkflowAssignment[];
|
||||
prototypeCandidates: PrototypeCandidate[];
|
||||
prototypeCleanupCompleted: boolean;
|
||||
deliveries: DeliveryRecord[];
|
||||
commandReceipts: CommandReceipt[];
|
||||
uiImpact?: UiImpact;
|
||||
uiImpactEvidence: string[];
|
||||
affectedUiSurfaces: string[];
|
||||
mutatorProfileNames: string[];
|
||||
reviewCycles: number;
|
||||
verificationCycles: number;
|
||||
verificationEvidence?: VerificationEvidence;
|
||||
reviewVerdict?: ReviewVerdict;
|
||||
error?: WorkflowError;
|
||||
}
|
||||
|
||||
export const ANALYSIS_TOOL_ALLOWLIST = ["read", "grep", "find", "ls"] as const;
|
||||
export const MAX_CONCURRENCY = 32;
|
||||
export const MAX_ASSIGNMENTS_PER_RUN = 1_024;
|
||||
export const MAX_REVIEW_CYCLES = 20;
|
||||
|
||||
export interface StandardAgentConfig {
|
||||
enabled: boolean;
|
||||
roles: Record<string, ProfileRole[]>;
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
concurrency: {
|
||||
default: number;
|
||||
ceiling: number;
|
||||
};
|
||||
maxAssignmentsPerRun: number;
|
||||
maxReviewCycles: number;
|
||||
analysisTools: string[];
|
||||
standardAgents: StandardAgentConfig;
|
||||
worktrees: {
|
||||
root: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = {
|
||||
concurrency: {
|
||||
default: 8,
|
||||
ceiling: MAX_CONCURRENCY,
|
||||
},
|
||||
maxAssignmentsPerRun: 128,
|
||||
maxReviewCycles: 3,
|
||||
analysisTools: [...ANALYSIS_TOOL_ALLOWLIST],
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: {
|
||||
scout: ["discover"],
|
||||
worker: ["implement"],
|
||||
},
|
||||
},
|
||||
worktrees: {
|
||||
root: ".worktrees",
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,871 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
DefaultResourceLoader,
|
||||
ExtensionContext,
|
||||
SettingsManager,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { emptyUsage } from "../_workflows/model.ts";
|
||||
import type {
|
||||
RunAgentOptions,
|
||||
ThinkingLevel,
|
||||
WorkflowModel,
|
||||
} from "../_workflows/runner.ts";
|
||||
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
|
||||
import { WorkflowEngine, type WorkspaceManager } from "./engine.ts";
|
||||
import {
|
||||
PiAssignmentExecutor,
|
||||
ProcessAssignmentScheduler,
|
||||
resolveWorkflowModel,
|
||||
} from "./execution.ts";
|
||||
import { loadAgentProfiles } from "./profiles.ts";
|
||||
import { WorkflowStore } from "./store.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-execution-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function model(provider: string, id: string) {
|
||||
return {
|
||||
provider,
|
||||
id,
|
||||
contextWindow: 128_000,
|
||||
} as unknown as WorkflowModel;
|
||||
}
|
||||
|
||||
function registry(models: WorkflowModel[]) {
|
||||
return {
|
||||
find(provider: string, id: string) {
|
||||
return models.find((candidate) => candidate.provider === provider && candidate.id === id);
|
||||
},
|
||||
getAll() {
|
||||
return models;
|
||||
},
|
||||
} as unknown as ExtensionContext["modelRegistry"];
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve: (() => void) | undefined;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve: () => resolve?.() };
|
||||
}
|
||||
|
||||
test("process scheduler enforces aggregate and per-run capacity with queued cancellation", async () => {
|
||||
const scheduler = new ProcessAssignmentScheduler();
|
||||
scheduler.lowerCeiling(2);
|
||||
const waits = [deferred(), deferred(), deferred(), deferred()];
|
||||
const controllers = waits.map(() => new AbortController());
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
const tasks = waits.map((wait, index) =>
|
||||
scheduler.schedule({
|
||||
runId: index < 3 ? "run-a" : "run-b",
|
||||
runLimit: 2,
|
||||
signal: controllers[index].signal,
|
||||
task: async () => {
|
||||
active++;
|
||||
maximum = Math.max(maximum, active);
|
||||
try {
|
||||
await wait.promise;
|
||||
return index;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
const cancelled = tasks[2].catch((error: unknown) => error);
|
||||
assert.deepEqual(scheduler.snapshot(), {
|
||||
ceiling: 2,
|
||||
inUse: 2,
|
||||
running: 0,
|
||||
queued: 2,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(scheduler.snapshot(), {
|
||||
ceiling: 2,
|
||||
inUse: 2,
|
||||
running: 2,
|
||||
queued: 2,
|
||||
});
|
||||
controllers[2].abort(new Error("cancel queued"));
|
||||
assert.match(String(await cancelled), /cancel queued/);
|
||||
waits[0].resolve();
|
||||
waits[1].resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
waits[3].resolve();
|
||||
assert.deepEqual(await Promise.all([tasks[0], tasks[1], tasks[3]]), [0, 1, 3]);
|
||||
assert.equal(maximum, 2);
|
||||
assert.deepEqual(scheduler.snapshot(), {
|
||||
ceiling: 2,
|
||||
inUse: 0,
|
||||
running: 0,
|
||||
queued: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("exclusive mutation reservations serialize a run without blocking other runs", async () => {
|
||||
const scheduler = new ProcessAssignmentScheduler();
|
||||
scheduler.lowerCeiling(3);
|
||||
const waits = [deferred(), deferred(), deferred(), deferred()];
|
||||
const started: number[] = [];
|
||||
const schedule = (index: number, runId: string, exclusive = false) =>
|
||||
scheduler.schedule({
|
||||
runId,
|
||||
runLimit: 3,
|
||||
exclusive,
|
||||
signal: new AbortController().signal,
|
||||
async task() {
|
||||
started.push(index);
|
||||
await waits[index].promise;
|
||||
},
|
||||
});
|
||||
const firstRead = schedule(0, "run-a");
|
||||
const mutation = schedule(1, "run-a", true);
|
||||
const laterRead = schedule(2, "run-a");
|
||||
const otherRun = schedule(3, "run-b");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(started, [0, 3]);
|
||||
waits[0].resolve();
|
||||
await firstRead;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(started, [0, 3, 1]);
|
||||
waits[1].resolve();
|
||||
await mutation;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(started, [0, 3, 1, 2]);
|
||||
waits[2].resolve();
|
||||
waits[3].resolve();
|
||||
await Promise.all([laterRead, otherRun]);
|
||||
});
|
||||
|
||||
test("running cancellation releases a scheduler reservation exactly once", async () => {
|
||||
const scheduler = new ProcessAssignmentScheduler();
|
||||
const controller = new AbortController();
|
||||
let reserved = false;
|
||||
let started = false;
|
||||
const task = scheduler.schedule({
|
||||
runId: "run-a",
|
||||
runLimit: 1,
|
||||
signal: controller.signal,
|
||||
async onReserved() {
|
||||
reserved = true;
|
||||
},
|
||||
async task(signal) {
|
||||
started = true;
|
||||
return new Promise<never>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort(new Error("cancel running"));
|
||||
await assert.rejects(task, /cancel running/);
|
||||
assert.equal(reserved, true);
|
||||
assert.equal(started, true);
|
||||
assert.deepEqual(scheduler.snapshot(), {
|
||||
ceiling: 32,
|
||||
inUse: 0,
|
||||
running: 0,
|
||||
queued: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("model resolution is exact, inherited-provider aware, and ambiguity safe", () => {
|
||||
const inherited = model("provider-a", "parent");
|
||||
const sharedA = model("provider-a", "shared");
|
||||
const sharedB = model("provider-b", "shared");
|
||||
const models = registry([inherited, sharedA, sharedB]);
|
||||
assert.equal(resolveWorkflowModel(models, {}, inherited), inherited);
|
||||
assert.throws(
|
||||
() => resolveWorkflowModel(registry([]), {}, inherited),
|
||||
/Unknown inherited model/,
|
||||
);
|
||||
assert.equal(resolveWorkflowModel(models, { model: "shared" }, inherited), sharedA);
|
||||
assert.equal(
|
||||
resolveWorkflowModel(models, { provider: "provider-b", model: "shared" }, inherited),
|
||||
sharedB,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveWorkflowModel(models, { model: "shared" }, undefined),
|
||||
/multiple providers/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveWorkflowModel(models, { provider: "provider-a" }, inherited),
|
||||
/requires a model/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the immutable process ceiling caps aggregate reservations at 32", async () => {
|
||||
const scheduler = new ProcessAssignmentScheduler();
|
||||
const waits = Array.from({ length: 40 }, deferred);
|
||||
let maximum = 0;
|
||||
let active = 0;
|
||||
const tasks = waits.map((wait, index) =>
|
||||
scheduler.schedule({
|
||||
runId: `run-${index % 4}`,
|
||||
runLimit: 32,
|
||||
signal: new AbortController().signal,
|
||||
task: async () => {
|
||||
active++;
|
||||
maximum = Math.max(maximum, active);
|
||||
try {
|
||||
await wait.promise;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(scheduler.snapshot().inUse, 32);
|
||||
assert.equal(scheduler.snapshot().queued, 8);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
waits.forEach((wait) => wait.resolve());
|
||||
await Promise.all(tasks);
|
||||
assert.equal(maximum, 32);
|
||||
assert.equal(scheduler.snapshot().inUse, 0);
|
||||
});
|
||||
|
||||
test("production executor resolves trust and creates fresh structured child runs", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const profiles = (
|
||||
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
||||
).profiles;
|
||||
const profile = profiles.find((candidate) => candidate.roles.includes("discover"));
|
||||
assert.ok(profile);
|
||||
const inherited = model("provider-a", "parent");
|
||||
const models = registry([inherited]);
|
||||
const scheduler = new ProcessAssignmentScheduler();
|
||||
let resources = 0;
|
||||
let sessions = 0;
|
||||
const trustedChildren: string[] = [];
|
||||
const observedTools: string[][] = [];
|
||||
const observedCustomTools: string[][] = [];
|
||||
const fakeResources = {
|
||||
loader: {} as DefaultResourceLoader,
|
||||
settingsManager: {} as SettingsManager,
|
||||
};
|
||||
const fakeRunAgent = async (options: RunAgentOptions) => {
|
||||
sessions++;
|
||||
observedTools.push(options.tools ?? []);
|
||||
observedCustomTools.push(
|
||||
options.customTools?.map((tool) => tool.name) ?? [],
|
||||
);
|
||||
options.onSessionCreated?.({
|
||||
childSessionId: `child-${sessions}`,
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
output: "",
|
||||
structured: {
|
||||
questions: [],
|
||||
candidatePaths: ["src/index.ts"],
|
||||
languages: ["typescript"],
|
||||
tags: ["backend"],
|
||||
riskTags: [],
|
||||
},
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: `child-${sessions}`,
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [{ role: "assistant" as const, text: "done" }],
|
||||
};
|
||||
};
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: models,
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: true,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 4,
|
||||
runConcurrency: 2,
|
||||
scheduler,
|
||||
runAgent: fakeRunAgent,
|
||||
async createResources() {
|
||||
resources++;
|
||||
return fakeResources;
|
||||
},
|
||||
resolveProjectTrust(options) {
|
||||
trustedChildren.push(options.childCwd);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const makeRequest = (runId: string) => ({
|
||||
runId,
|
||||
role: "discover" as const,
|
||||
request: "Inspect the repository",
|
||||
profile,
|
||||
facts: {
|
||||
stage: "discover" as const,
|
||||
taskKind: "feature" as const,
|
||||
requestTerms: ["inspect"],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
decisions: [],
|
||||
cwd: path.join(directory, "worktree"),
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const first = await executor.run(makeRequest("run-a"));
|
||||
const second = await executor.run(makeRequest("run-b"));
|
||||
await executor.run({
|
||||
...makeRequest("run-c"),
|
||||
allowedReadTools: ["grep"],
|
||||
});
|
||||
assert.equal(first.kind, "workflow-assignment-result");
|
||||
assert.equal(first.metadata.childSessionId, "child-1");
|
||||
assert.equal(second.metadata.childSessionId, "child-2");
|
||||
assert.equal(resources, 3);
|
||||
assert.equal(sessions, 3);
|
||||
assert.deepEqual(trustedChildren, [
|
||||
path.join(directory, "worktree"),
|
||||
path.join(directory, "worktree"),
|
||||
path.join(directory, "worktree"),
|
||||
]);
|
||||
assert.ok(observedTools.every((tools) => tools.includes("structured_output")));
|
||||
assert.equal(observedTools[2]?.includes("read"), false);
|
||||
assert.equal(observedTools[2]?.includes("grep"), true);
|
||||
assert.deepEqual(observedCustomTools[2], ["grep"]);
|
||||
|
||||
const failedExecutor = new PiAssignmentExecutor({
|
||||
modelRegistry: models,
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: true,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 4,
|
||||
runConcurrency: 2,
|
||||
scheduler: new ProcessAssignmentScheduler(),
|
||||
async createResources() {
|
||||
return fakeResources;
|
||||
},
|
||||
async runAgent(options) {
|
||||
options.onSessionCreated?.({
|
||||
childSessionId: "child-failed",
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
output: "partial",
|
||||
error: "provider failed",
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: "child-failed",
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [{ role: "assistant" as const, text: "partial" }],
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
failedExecutor.run(makeRequest("run-failed")),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, "provider failed");
|
||||
assert.equal(
|
||||
(error as Error & { assignmentMetadata?: { childSessionId?: string } })
|
||||
.assignmentMetadata?.childSessionId,
|
||||
"child-failed",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("mutating children receive guarded file tools without process execution", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
await execFileAsync("git", ["init", "-q", directory]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
|
||||
await mkdir(path.join(directory, "src"), { recursive: true });
|
||||
await writeFile(path.join(directory, "src", "existing.ts"), "export {};\n", "utf8");
|
||||
await execFileAsync("git", ["-C", directory, "add", "."]);
|
||||
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
||||
const profiles = (
|
||||
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
||||
).profiles;
|
||||
const profile = profiles.find((candidate) => candidate.roles.includes("implement"));
|
||||
assert.ok(profile);
|
||||
const standardWorker = {
|
||||
...profile,
|
||||
name: "worker",
|
||||
source: "standard-package" as const,
|
||||
tools: [...(profile.tools ?? []), "bash", "subagent"],
|
||||
};
|
||||
const inherited = model("provider-a", "parent");
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: registry([inherited]),
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: false,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 2,
|
||||
runConcurrency: 1,
|
||||
scheduler: new ProcessAssignmentScheduler(),
|
||||
async createResources() {
|
||||
return {
|
||||
loader: {} as DefaultResourceLoader,
|
||||
settingsManager: {} as SettingsManager,
|
||||
};
|
||||
},
|
||||
async runAgent(options) {
|
||||
assert.deepEqual(options.tools, [
|
||||
"read",
|
||||
"grep",
|
||||
"find",
|
||||
"ls",
|
||||
"edit",
|
||||
"write",
|
||||
"structured_output",
|
||||
]);
|
||||
assert.equal(options.tools?.includes("bash"), false);
|
||||
const writeTool = options.customTools?.find((tool) => tool.name === "write");
|
||||
assert.ok(writeTool);
|
||||
await writeTool.execute(
|
||||
"write-1",
|
||||
{ path: "src/generated.ts", content: "export const generated = true;\n" },
|
||||
options.signal ?? new AbortController().signal,
|
||||
undefined,
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
output: "",
|
||||
structured: {
|
||||
plan: "# Plan",
|
||||
uiImpact: "none",
|
||||
uiImpactEvidence: ["No user-facing change"],
|
||||
affectedUiSurfaces: [],
|
||||
assignments: [],
|
||||
},
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: "mutating-child",
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await executor.run({
|
||||
runId: "run-mutation",
|
||||
role: "design",
|
||||
request: "Generate an owned file",
|
||||
profile: standardWorker,
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["generate"],
|
||||
candidatePaths: ["src/generated.ts"],
|
||||
languages: ["typescript"],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
decisions: [],
|
||||
cwd: directory,
|
||||
signal: new AbortController().signal,
|
||||
mutates: true,
|
||||
ownedPaths: ["src"],
|
||||
});
|
||||
assert.deepEqual(result.metadata.gitAfter?.changedPaths, ["src/generated.ts"]);
|
||||
|
||||
const prototypePath = path.join(directory, "prototypes", "candidate-1");
|
||||
await mkdir(prototypePath, { recursive: true });
|
||||
await writeFile(path.join(prototypePath, "route.ts"), "prototype\n", "utf8");
|
||||
const cleanupExecutor = new PiAssignmentExecutor({
|
||||
modelRegistry: registry([inherited]),
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: false,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 2,
|
||||
runConcurrency: 1,
|
||||
scheduler: new ProcessAssignmentScheduler(),
|
||||
async createResources() {
|
||||
return {
|
||||
loader: {} as DefaultResourceLoader,
|
||||
settingsManager: {} as SettingsManager,
|
||||
};
|
||||
},
|
||||
async runAgent(options) {
|
||||
assert.equal(options.tools?.includes("workflow_remove"), true);
|
||||
const removeTool = options.customTools?.find(
|
||||
(tool) => tool.name === "workflow_remove",
|
||||
);
|
||||
assert.ok(removeTool);
|
||||
await removeTool.execute(
|
||||
"remove-prototype",
|
||||
{ path: "prototypes/candidate-1" },
|
||||
options.signal ?? new AbortController().signal,
|
||||
undefined,
|
||||
{} as ExtensionContext,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
output: "",
|
||||
structured: {
|
||||
summary: "Removed prototype-only route",
|
||||
changedPaths: ["prototypes/candidate-1"],
|
||||
focusedTests: [],
|
||||
},
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: "cleanup-child",
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
await cleanupExecutor.run({
|
||||
runId: "run-cleanup",
|
||||
role: "remediate",
|
||||
request: "Remove prototype-only routes",
|
||||
profile,
|
||||
facts: {
|
||||
stage: "implement",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["cleanup"],
|
||||
candidatePaths: ["prototypes/candidate-1"],
|
||||
languages: ["typescript"],
|
||||
tags: ["frontend"],
|
||||
riskTags: [],
|
||||
},
|
||||
decisions: [],
|
||||
cwd: directory,
|
||||
signal: new AbortController().signal,
|
||||
mutates: true,
|
||||
ownedPaths: ["prototypes/candidate-1"],
|
||||
});
|
||||
await assert.rejects(access(prototypePath));
|
||||
|
||||
const reviewProfile = profiles.find((candidate) => candidate.roles.includes("review"));
|
||||
assert.ok(reviewProfile);
|
||||
const reviewRequest = {
|
||||
runId: "run-review",
|
||||
role: "review" as const,
|
||||
request: "Review the feature",
|
||||
profile: reviewProfile,
|
||||
facts: {
|
||||
stage: "review" as const,
|
||||
taskKind: "feature" as const,
|
||||
requestTerms: ["review"],
|
||||
candidatePaths: ["src/generated.ts"],
|
||||
languages: ["typescript"],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
decisions: [],
|
||||
cwd: directory,
|
||||
signal: new AbortController().signal,
|
||||
captureGitState: true,
|
||||
approvedPlan: "# Approved plan",
|
||||
verificationEvidence: {
|
||||
commands: [],
|
||||
focusedBehavioralTests: true,
|
||||
verifierSucceeded: true,
|
||||
},
|
||||
reviewDiff: "a😀b",
|
||||
};
|
||||
const reviewExecutor = (
|
||||
readCompleteDiff: boolean,
|
||||
readCompletePlan = readCompleteDiff,
|
||||
) =>
|
||||
new PiAssignmentExecutor({
|
||||
modelRegistry: registry([inherited]),
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: false,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 2,
|
||||
runConcurrency: 1,
|
||||
scheduler: new ProcessAssignmentScheduler(),
|
||||
async createResources() {
|
||||
return {
|
||||
loader: {} as DefaultResourceLoader,
|
||||
settingsManager: {} as SettingsManager,
|
||||
};
|
||||
},
|
||||
async runAgent(options) {
|
||||
assert.equal(options.tools?.includes("workflow_diff"), true);
|
||||
assert.equal(options.tools?.includes("workflow_plan"), true);
|
||||
assert.equal(
|
||||
options.customTools?.some((tool) => tool.name === "workflow_diff"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
options.customTools?.some((tool) => tool.name === "workflow_plan"),
|
||||
true,
|
||||
);
|
||||
const diffTool = options.customTools?.find(
|
||||
(tool) => tool.name === "workflow_diff",
|
||||
);
|
||||
assert.ok(diffTool);
|
||||
const executeTool = diffTool.execute as unknown as (
|
||||
id: string,
|
||||
params: { offset: number; limit: number },
|
||||
) => Promise<{ content: Array<{ type: string; text: string }> }>;
|
||||
const unicodePage = await executeTool("unicode-page", {
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(unicodePage.content[0]?.text, "😀");
|
||||
return {
|
||||
ok: true,
|
||||
output: "",
|
||||
structured: {
|
||||
specification: { verdict: "approved", findings: [] },
|
||||
standards: { verdict: "approved", findings: [] },
|
||||
},
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: "review-child",
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [
|
||||
...(readCompletePlan
|
||||
? [
|
||||
{
|
||||
role: "tool" as const,
|
||||
name: "workflow_plan",
|
||||
toolCallId: "plan-call",
|
||||
text: '{"offset":0,"limit":49152}',
|
||||
},
|
||||
{
|
||||
role: "toolResult" as const,
|
||||
name: "workflow_plan",
|
||||
toolCallId: "plan-call",
|
||||
text: "plan page",
|
||||
isError: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(readCompleteDiff
|
||||
? [
|
||||
{
|
||||
role: "tool" as const,
|
||||
name: "workflow_diff",
|
||||
toolCallId: "diff-call",
|
||||
text: '{"offset":0,"limit":49152}',
|
||||
},
|
||||
{
|
||||
role: "toolResult" as const,
|
||||
name: "workflow_diff",
|
||||
toolCallId: "diff-call",
|
||||
text: "diff page",
|
||||
isError: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
await reviewExecutor(true).run(reviewRequest);
|
||||
await assert.rejects(
|
||||
reviewExecutor(false).run(reviewRequest),
|
||||
/did not read the complete workflow diff/,
|
||||
);
|
||||
await assert.rejects(
|
||||
reviewExecutor(true, false).run(reviewRequest),
|
||||
/did not read the complete approved workflow plan/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("production executor drives Discover and Design through Plan Approval", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const profiles = (
|
||||
await loadAgentProfiles({ cwd: directory, projectTrusted: false })
|
||||
).profiles;
|
||||
const profileName = (role: "implement" | "verify" | "review") => {
|
||||
const profile = profiles.find((candidate) => candidate.roles.includes(role));
|
||||
assert.ok(profile);
|
||||
return profile.name;
|
||||
};
|
||||
const inherited = model("provider-a", "parent");
|
||||
const models = registry([inherited]);
|
||||
let sessions = 0;
|
||||
const fakeRunAgent = async (options: RunAgentOptions) => {
|
||||
sessions++;
|
||||
options.onSessionCreated?.({
|
||||
childSessionId: `pipeline-child-${sessions}`,
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
});
|
||||
const structured = options.prompt.includes("Assignment role: discover")
|
||||
? {
|
||||
questions: [],
|
||||
candidatePaths: ["src/index.ts"],
|
||||
languages: ["typescript"],
|
||||
tags: ["backend"],
|
||||
riskTags: [],
|
||||
}
|
||||
: {
|
||||
plan: "# Plan\n\nImplement and verify the feature.",
|
||||
uiImpact: "none",
|
||||
uiImpactEvidence: ["No user-facing change"],
|
||||
affectedUiSurfaces: [],
|
||||
acceptanceCriteria: ["Feature behavior passes focused tests"],
|
||||
verificationCommands: [
|
||||
{
|
||||
executable: "npm",
|
||||
args: ["test"],
|
||||
ownedPaths: [],
|
||||
kind: "test",
|
||||
},
|
||||
],
|
||||
assignments: [
|
||||
{
|
||||
role: "implement",
|
||||
profileName: profileName("implement"),
|
||||
rationale: "Implement the approved scope",
|
||||
mutates: true,
|
||||
ownedPaths: ["src"],
|
||||
},
|
||||
{
|
||||
role: "verify",
|
||||
profileName: profileName("verify"),
|
||||
rationale: "Run focused verification",
|
||||
mutates: false,
|
||||
ownedPaths: [],
|
||||
},
|
||||
{
|
||||
role: "review",
|
||||
profileName: profileName("review"),
|
||||
rationale: "Review independently",
|
||||
mutates: false,
|
||||
ownedPaths: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
output: "",
|
||||
structured,
|
||||
aborted: false,
|
||||
usage: emptyUsage(),
|
||||
provider: inherited.provider,
|
||||
model: inherited.id,
|
||||
thinkingLevel: "medium" as ThinkingLevel,
|
||||
childSessionId: `pipeline-child-${sessions}`,
|
||||
contextWindow: inherited.contextWindow,
|
||||
transcript: [{ role: "assistant" as const, text: "done" }],
|
||||
};
|
||||
};
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: models,
|
||||
parentCwd: directory,
|
||||
parentProjectTrusted: false,
|
||||
parentModel: inherited,
|
||||
parentThinkingLevel: "medium" as ThinkingLevel,
|
||||
concurrencyCeiling: 4,
|
||||
runConcurrency: 2,
|
||||
scheduler: new ProcessAssignmentScheduler(),
|
||||
runAgent: fakeRunAgent,
|
||||
async createResources() {
|
||||
return {
|
||||
loader: {} as DefaultResourceLoader,
|
||||
settingsManager: {} as SettingsManager,
|
||||
};
|
||||
},
|
||||
});
|
||||
const workspace: WorkspaceManager = {
|
||||
async prepare(options) {
|
||||
return {
|
||||
sourceRoot: options.sourceRoot,
|
||||
worktreeRoot: path.join(options.sourceRoot, ".worktrees"),
|
||||
worktreePath: path.join(options.sourceRoot, ".worktrees", options.runId),
|
||||
branch: `feature/${options.featureName}`,
|
||||
baseHead: "abc123",
|
||||
branchExists: false,
|
||||
sourceChanges: [],
|
||||
sourceChangesOmitted: 0,
|
||||
dirtySourceAllowed: false,
|
||||
};
|
||||
},
|
||||
async create(plan): Promise<WorkflowRun["workspace"]> {
|
||||
return {
|
||||
state: "ready",
|
||||
sourceRoot: plan.sourceRoot,
|
||||
root: plan.worktreePath,
|
||||
branch: plan.branch,
|
||||
baseHead: plan.baseHead,
|
||||
};
|
||||
},
|
||||
async reconcile(current) {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
let id = 0;
|
||||
const engine = new WorkflowEngine({
|
||||
store: new WorkflowStore(path.join(directory, "state")),
|
||||
workspace,
|
||||
executor,
|
||||
sourceRoot: directory,
|
||||
parentSessionId: "session-1",
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
profiles,
|
||||
now: () => 10_000,
|
||||
createId: () => `pipeline-${++id}`,
|
||||
processId: 100,
|
||||
isProcessAlive: () => true,
|
||||
});
|
||||
const result = await engine.execute({
|
||||
action: "start",
|
||||
request: "Build the feature",
|
||||
featureName: "build-feature",
|
||||
idempotencyKey: "pipeline-start",
|
||||
});
|
||||
assert.equal(result.openGate?.kind, "plan-approval");
|
||||
assert.equal(sessions, 2);
|
||||
assert.deepEqual(
|
||||
result.assignments
|
||||
.filter((assignment) => assignment.childSessionId)
|
||||
.map((assignment) => assignment.childSessionId),
|
||||
["pipeline-child-1", "pipeline-child-2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.assignments
|
||||
.filter((assignment) => assignment.status === "queued")
|
||||
.map((assignment) => assignment.role),
|
||||
["implement", "verify", "review"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 "@_jb/pi-shared/child-session";
|
||||
import {
|
||||
runAgent,
|
||||
type AgentOutcome,
|
||||
type RunAgentOptions,
|
||||
type ThinkingLevel,
|
||||
type WorkflowModel,
|
||||
} from "../_workflows/runner.ts";
|
||||
import { STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION } from "../_workflows/prompt.ts";
|
||||
import type {
|
||||
AssignmentExecutionError,
|
||||
AssignmentExecutionMetadata,
|
||||
AssignmentExecutionResult,
|
||||
AssignmentRequest,
|
||||
AssignmentExecutor,
|
||||
} from "./engine.ts";
|
||||
import { GitStateProbe, MutationScope } from "./workspace.ts";
|
||||
|
||||
const PROCESS_MAX_ASSIGNMENTS = 32;
|
||||
|
||||
interface ScheduledAssignment {
|
||||
runId: string;
|
||||
runLimit: number;
|
||||
exclusive: boolean;
|
||||
signal: AbortSignal;
|
||||
start: () => void;
|
||||
reject: (error: Error) => void;
|
||||
state: "queued" | "reserved" | "running" | "settled";
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal) {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("Assignment was cancelled");
|
||||
}
|
||||
|
||||
/** Process-wide capacity with synchronous reservation and cancellation-safe release. */
|
||||
export class ProcessAssignmentScheduler {
|
||||
private ceiling = PROCESS_MAX_ASSIGNMENTS;
|
||||
private inUse = 0;
|
||||
private running = 0;
|
||||
private readonly runUsage = new Map<string, number>();
|
||||
private readonly exclusiveRuns = new Set<string>();
|
||||
private readonly queue: ScheduledAssignment[] = [];
|
||||
|
||||
lowerCeiling(ceiling: number) {
|
||||
if (!Number.isInteger(ceiling) || ceiling < 1 || ceiling > PROCESS_MAX_ASSIGNMENTS) {
|
||||
throw new Error(`Assignment ceiling must be between 1 and ${PROCESS_MAX_ASSIGNMENTS}`);
|
||||
}
|
||||
this.ceiling = Math.min(this.ceiling, ceiling);
|
||||
this.drain();
|
||||
}
|
||||
|
||||
schedule<T>(options: {
|
||||
runId: string;
|
||||
runLimit: number;
|
||||
signal: AbortSignal;
|
||||
exclusive?: boolean;
|
||||
onReserved?: () => Promise<void>;
|
||||
task: (signal: AbortSignal) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
if (!Number.isInteger(options.runLimit) || options.runLimit < 1) {
|
||||
return Promise.reject(new Error("Workflow Run assignment limit must be positive"));
|
||||
}
|
||||
if (options.signal.aborted) return Promise.reject(abortError(options.signal));
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const entry: ScheduledAssignment = {
|
||||
runId: options.runId,
|
||||
runLimit: options.runLimit,
|
||||
signal: options.signal,
|
||||
exclusive: options.exclusive ?? false,
|
||||
state: "queued",
|
||||
reject,
|
||||
onAbort: () => {
|
||||
if (entry.state !== "queued") return;
|
||||
entry.state = "settled";
|
||||
const index = this.queue.indexOf(entry);
|
||||
if (index >= 0) this.queue.splice(index, 1);
|
||||
options.signal.removeEventListener("abort", entry.onAbort);
|
||||
reject(abortError(options.signal));
|
||||
},
|
||||
start: () => {
|
||||
queueMicrotask(async () => {
|
||||
if (entry.state !== "reserved") return;
|
||||
entry.state = "running";
|
||||
this.running++;
|
||||
try {
|
||||
if (options.signal.aborted) throw abortError(options.signal);
|
||||
await options.onReserved?.();
|
||||
if (options.signal.aborted) throw abortError(options.signal);
|
||||
resolve(await options.task(options.signal));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.running--;
|
||||
this.release(entry);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
options.signal.addEventListener("abort", entry.onAbort, { once: true });
|
||||
this.queue.push(entry);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
ceiling: this.ceiling,
|
||||
inUse: this.inUse,
|
||||
running: this.running,
|
||||
queued: this.queue.filter((entry) => entry.state === "queued").length,
|
||||
};
|
||||
}
|
||||
|
||||
private drain() {
|
||||
while (this.inUse < this.ceiling) {
|
||||
const index = this.queue.findIndex((entry, entryIndex) => {
|
||||
if (entry.state !== "queued") return false;
|
||||
const usage = this.runUsage.get(entry.runId) ?? 0;
|
||||
if (entry.exclusive) return usage === 0;
|
||||
if (this.exclusiveRuns.has(entry.runId) || usage >= entry.runLimit) {
|
||||
return false;
|
||||
}
|
||||
return !this.queue
|
||||
.slice(0, entryIndex)
|
||||
.some(
|
||||
(queued) =>
|
||||
queued.state === "queued" &&
|
||||
queued.runId === entry.runId &&
|
||||
queued.exclusive,
|
||||
);
|
||||
});
|
||||
if (index < 0) return;
|
||||
const entry = this.queue.splice(index, 1)[0];
|
||||
if (!entry || entry.signal.aborted) {
|
||||
entry?.onAbort();
|
||||
continue;
|
||||
}
|
||||
entry.state = "reserved";
|
||||
this.inUse++;
|
||||
this.runUsage.set(entry.runId, (this.runUsage.get(entry.runId) ?? 0) + 1);
|
||||
if (entry.exclusive) this.exclusiveRuns.add(entry.runId);
|
||||
entry.start();
|
||||
}
|
||||
}
|
||||
|
||||
private release(entry: ScheduledAssignment) {
|
||||
if (entry.state === "settled") return;
|
||||
entry.state = "settled";
|
||||
entry.signal.removeEventListener("abort", entry.onAbort);
|
||||
this.inUse--;
|
||||
if (entry.exclusive) this.exclusiveRuns.delete(entry.runId);
|
||||
const remaining = (this.runUsage.get(entry.runId) ?? 1) - 1;
|
||||
if (remaining === 0) this.runUsage.delete(entry.runId);
|
||||
else this.runUsage.set(entry.runId, remaining);
|
||||
this.drain();
|
||||
}
|
||||
}
|
||||
|
||||
export const processAssignmentScheduler = new ProcessAssignmentScheduler();
|
||||
|
||||
export function resolveWorkflowModel(
|
||||
registry: ExtensionContext["modelRegistry"],
|
||||
profile: { provider?: string; model?: string },
|
||||
inherited: WorkflowModel | undefined,
|
||||
): Model<Api> | undefined {
|
||||
if (profile.provider && !profile.model) {
|
||||
throw new Error(`Agent Profile provider ${profile.provider} requires a model`);
|
||||
}
|
||||
if (!profile.model) {
|
||||
if (!inherited) return undefined;
|
||||
const found = registry.find(inherited.provider, inherited.id);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Unknown inherited model ${inherited.provider}/${inherited.id}`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
if (profile.provider) {
|
||||
const found = registry.find(profile.provider, profile.model);
|
||||
if (!found) throw new Error(`Unknown model ${profile.provider}/${profile.model}`);
|
||||
return found;
|
||||
}
|
||||
const slash = profile.model.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const provider = profile.model.slice(0, slash);
|
||||
const model = profile.model.slice(slash + 1);
|
||||
const found = registry.find(provider, model);
|
||||
if (!found) throw new Error(`Unknown model ${profile.model}`);
|
||||
return found;
|
||||
}
|
||||
if (inherited) {
|
||||
const found = registry.find(inherited.provider, profile.model);
|
||||
if (found) return found;
|
||||
}
|
||||
const matches = registry.getAll().filter((model) => model.id === profile.model);
|
||||
if (matches.length === 1) return matches[0];
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Model ${profile.model} exists in multiple providers; use provider/model`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Unknown model ${profile.model}`);
|
||||
}
|
||||
|
||||
const DISCOVERY_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"questions",
|
||||
"candidatePaths",
|
||||
"languages",
|
||||
"tags",
|
||||
"riskTags",
|
||||
],
|
||||
properties: {
|
||||
questions: {
|
||||
type: "array",
|
||||
maxItems: 20,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["id", "prompt"],
|
||||
properties: {
|
||||
id: { type: "string", maxLength: 160 },
|
||||
prompt: { type: "string", maxLength: 2000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
candidatePaths: { type: "array", items: { type: "string" } },
|
||||
languages: { type: "array", items: { type: "string" } },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
riskTags: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const DESIGN_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"plan",
|
||||
"uiImpact",
|
||||
"uiImpactEvidence",
|
||||
"affectedUiSurfaces",
|
||||
"assignments",
|
||||
"acceptanceCriteria",
|
||||
"verificationCommands",
|
||||
],
|
||||
properties: {
|
||||
plan: { type: "string" },
|
||||
uiImpact: { enum: ["none", "minor", "non-trivial"] },
|
||||
uiImpactEvidence: { type: "array", items: { type: "string" } },
|
||||
affectedUiSurfaces: { type: "array", items: { type: "string" } },
|
||||
acceptanceCriteria: { type: "array", items: { type: "string" } },
|
||||
verificationCommands: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["executable", "args", "ownedPaths", "kind"],
|
||||
properties: {
|
||||
executable: { type: "string" },
|
||||
args: { type: "array", items: { type: "string" } },
|
||||
ownedPaths: { type: "array", items: { type: "string" } },
|
||||
kind: {
|
||||
enum: ["test", "typecheck", "lint", "build", "browser", "generation"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
type: "array",
|
||||
minItems: 3,
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["role", "profileName", "rationale", "mutates", "ownedPaths"],
|
||||
properties: {
|
||||
role: { enum: ["prototype", "implement", "verify", "review"] },
|
||||
profileName: { type: "string" },
|
||||
rationale: { type: "string" },
|
||||
mutates: { type: "boolean" },
|
||||
ownedPaths: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROTOTYPE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"path",
|
||||
"route",
|
||||
"structuralRationale",
|
||||
"responsiveChecks",
|
||||
"browserEvidence",
|
||||
"launchInstructions",
|
||||
],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
route: { type: "string" },
|
||||
structuralRationale: { type: "string" },
|
||||
responsiveChecks: { type: "array", items: { type: "string" } },
|
||||
browserEvidence: { type: "array", items: { type: "string" } },
|
||||
launchInstructions: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const IMPLEMENTATION_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["summary", "changedPaths", "focusedTests"],
|
||||
properties: {
|
||||
summary: { type: "string" },
|
||||
changedPaths: { type: "array", items: { type: "string" } },
|
||||
focusedTests: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const VERIFICATION_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["succeeded", "focusedBehavioralTests", "summary"],
|
||||
properties: {
|
||||
succeeded: { type: "boolean" },
|
||||
focusedBehavioralTests: { type: "boolean" },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const REVIEW_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["specification", "standards"],
|
||||
properties: {
|
||||
specification: { $ref: "#/$defs/axis" },
|
||||
standards: { $ref: "#/$defs/axis" },
|
||||
},
|
||||
$defs: {
|
||||
axis: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["verdict", "findings"],
|
||||
properties: {
|
||||
verdict: { enum: ["approved", "changes_requested"] },
|
||||
findings: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["severity", "message"],
|
||||
properties: {
|
||||
severity: { type: "string" },
|
||||
message: { type: "string" },
|
||||
path: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
function schemaForRole(role: AssignmentRequest["role"]) {
|
||||
if (role === "discover") return DISCOVERY_SCHEMA;
|
||||
if (role === "design") return DESIGN_SCHEMA;
|
||||
if (role === "prototype") return PROTOTYPE_SCHEMA;
|
||||
if (role === "verify") return VERIFICATION_SCHEMA;
|
||||
if (role === "review") return REVIEW_SCHEMA;
|
||||
return IMPLEMENTATION_SCHEMA;
|
||||
}
|
||||
|
||||
function pagedTextTool(options: {
|
||||
name: "workflow_diff" | "workflow_plan";
|
||||
label: string;
|
||||
description: string;
|
||||
contents: string;
|
||||
}) {
|
||||
const characters = [...options.contents];
|
||||
return defineTool({
|
||||
name: options.name,
|
||||
label: options.label,
|
||||
description: options.description,
|
||||
parameters: Type.Object({
|
||||
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 48 * 1024 })),
|
||||
}),
|
||||
async execute(_id, params) {
|
||||
const offset = params.offset ?? 0;
|
||||
const limit = params.limit ?? 48 * 1024;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: characters.slice(offset, offset + limit).join(""),
|
||||
},
|
||||
],
|
||||
details: { offset, limit, totalCharacters: characters.length },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readCompletePagedText(
|
||||
transcript: AssignmentExecutionMetadata["transcript"],
|
||||
toolName: "workflow_diff" | "workflow_plan",
|
||||
totalCharacters: number,
|
||||
) {
|
||||
if (totalCharacters === 0) return true;
|
||||
const entries = transcript.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is {
|
||||
role: string;
|
||||
name?: string;
|
||||
text: string;
|
||||
toolCallId?: string;
|
||||
isError?: boolean;
|
||||
} =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
"role" in entry &&
|
||||
typeof entry.role === "string" &&
|
||||
"text" in entry &&
|
||||
typeof entry.text === "string",
|
||||
);
|
||||
const successfulCallIds = new Set(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.role === "toolResult" &&
|
||||
entry.name === toolName &&
|
||||
!entry.isError &&
|
||||
typeof entry.toolCallId === "string",
|
||||
)
|
||||
.map((entry) => entry.toolCallId!),
|
||||
);
|
||||
const intervals = entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.role === "tool" &&
|
||||
typeof entry.toolCallId === "string" &&
|
||||
successfulCallIds.has(entry.toolCallId) &&
|
||||
entry.name === toolName,
|
||||
)
|
||||
.map((entry) => {
|
||||
try {
|
||||
const input = JSON.parse(entry.text) as { offset?: unknown; limit?: unknown };
|
||||
const offset = input.offset === undefined ? 0 : input.offset;
|
||||
const limit = input.limit === undefined ? 48 * 1024 : input.limit;
|
||||
if (
|
||||
typeof offset !== "number" ||
|
||||
!Number.isInteger(offset) ||
|
||||
offset < 0 ||
|
||||
typeof limit !== "number" ||
|
||||
!Number.isInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > 48 * 1024
|
||||
) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
return [offset, Math.min(totalCharacters, offset + limit)] as const;
|
||||
} catch {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left[0] - right[0]);
|
||||
let covered = 0;
|
||||
for (const [start, end] of intervals) {
|
||||
if (start > covered) return false;
|
||||
covered = Math.max(covered, end);
|
||||
if (covered >= totalCharacters) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assignmentPrompt(request: AssignmentRequest) {
|
||||
const plannerCandidates = request.plannerCandidates?.map((entry) => ({
|
||||
role: entry.role,
|
||||
candidates: entry.candidates
|
||||
.filter((candidate) => candidate.eligible)
|
||||
.map((candidate) => ({
|
||||
name: candidate.profileName,
|
||||
digest: candidate.profileDigest,
|
||||
specificity: candidate.specificity,
|
||||
})),
|
||||
}));
|
||||
return [
|
||||
`Assignment role: ${request.role}`,
|
||||
`Workflow request: ${request.request}`,
|
||||
`Repository facts: ${JSON.stringify(request.facts)}`,
|
||||
`Accepted decisions: ${JSON.stringify(request.decisions.map((item) => item.value))}`,
|
||||
...(plannerCandidates
|
||||
? [`Eligible planner candidates: ${JSON.stringify(plannerCandidates)}`]
|
||||
: []),
|
||||
...(request.approvedPlan
|
||||
? request.role === "verify" || request.role === "review"
|
||||
? [
|
||||
`Approved plan: ${[...request.approvedPlan].length} characters available through workflow_plan. Read the complete plan.`,
|
||||
]
|
||||
: [`Approved plan:\n${request.approvedPlan}`]
|
||||
: []),
|
||||
...(request.verificationEvidence
|
||||
? [
|
||||
`Persisted verification evidence: ${JSON.stringify(request.verificationEvidence)}`,
|
||||
]
|
||||
: []),
|
||||
...(request.reviewDiff
|
||||
? [
|
||||
`Review diff: ${[...request.reviewDiff].length} characters available through workflow_diff. Review the complete scope.`,
|
||||
]
|
||||
: []),
|
||||
"Follow the active Agent Profile instructions. Return only through structured_output.",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
interface ChildResources {
|
||||
loader: DefaultResourceLoader;
|
||||
settingsManager: SettingsManager;
|
||||
}
|
||||
|
||||
export interface PiAssignmentExecutorOptions {
|
||||
modelRegistry: ExtensionContext["modelRegistry"];
|
||||
parentCwd: string;
|
||||
parentProjectTrusted: boolean;
|
||||
parentModel?: WorkflowModel;
|
||||
parentThinkingLevel: ThinkingLevel;
|
||||
concurrencyCeiling: number;
|
||||
runConcurrency: number;
|
||||
scheduler?: ProcessAssignmentScheduler;
|
||||
runAgent?: (options: RunAgentOptions) => Promise<AgentOutcome>;
|
||||
createResources?: (options: {
|
||||
cwd: string;
|
||||
projectTrusted: boolean;
|
||||
appendSystemPrompt: string[];
|
||||
}) => Promise<ChildResources>;
|
||||
resolveProjectTrust?: typeof resolveStandaloneChildProjectTrust;
|
||||
}
|
||||
|
||||
export class PiAssignmentExecutor implements AssignmentExecutor {
|
||||
readonly reservesCapacity = true;
|
||||
private readonly options: PiAssignmentExecutorOptions;
|
||||
private readonly scheduler: ProcessAssignmentScheduler;
|
||||
|
||||
constructor(options: PiAssignmentExecutorOptions) {
|
||||
this.options = options;
|
||||
this.scheduler = options.scheduler ?? processAssignmentScheduler;
|
||||
this.scheduler.lowerCeiling(options.concurrencyCeiling);
|
||||
}
|
||||
|
||||
async run(request: AssignmentRequest): Promise<AssignmentExecutionResult> {
|
||||
const model = resolveWorkflowModel(
|
||||
this.options.modelRegistry,
|
||||
request.profile,
|
||||
this.options.parentModel,
|
||||
) as WorkflowModel | undefined;
|
||||
const thinkingLevel =
|
||||
(request.profile.effort as ThinkingLevel | undefined) ??
|
||||
this.options.parentThinkingLevel;
|
||||
const resolveTrust =
|
||||
this.options.resolveProjectTrust ?? resolveStandaloneChildProjectTrust;
|
||||
const projectTrusted = resolveTrust({
|
||||
parentCwd: this.options.parentCwd,
|
||||
childCwd: request.cwd,
|
||||
parentTrusted: this.options.parentProjectTrusted,
|
||||
});
|
||||
const createResources = this.options.createResources ?? createChildResources;
|
||||
const resources = await createResources({
|
||||
cwd: request.cwd,
|
||||
projectTrusted,
|
||||
appendSystemPrompt: [
|
||||
request.profile.instructions,
|
||||
STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION,
|
||||
],
|
||||
});
|
||||
const execute = this.options.runAgent ?? runAgent;
|
||||
const mutationScope = request.mutates
|
||||
? await MutationScope.create(request.cwd, request.ownedPaths ?? [])
|
||||
: undefined;
|
||||
const gitProbe =
|
||||
mutationScope || request.captureGitState ? new GitStateProbe() : undefined;
|
||||
const evidenceTools = [
|
||||
...(request.reviewDiff
|
||||
? [
|
||||
pagedTextTool({
|
||||
name: "workflow_diff",
|
||||
label: "Workflow Diff",
|
||||
description:
|
||||
"Read the complete review diff in bounded character ranges.",
|
||||
contents: request.reviewDiff,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(request.approvedPlan &&
|
||||
(request.role === "verify" || request.role === "review")
|
||||
? [
|
||||
pagedTextTool({
|
||||
name: "workflow_plan",
|
||||
label: "Workflow Plan",
|
||||
description:
|
||||
"Read the complete approved plan in bounded character ranges.",
|
||||
contents: request.approvedPlan,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const allowedReadTools = new Set(
|
||||
request.allowedReadTools ?? ["read", "grep", "find", "ls"],
|
||||
);
|
||||
const auditedReadTools = [
|
||||
createReadToolDefinition(request.cwd),
|
||||
createGrepToolDefinition(request.cwd),
|
||||
createFindToolDefinition(request.cwd),
|
||||
createLsToolDefinition(request.cwd),
|
||||
].filter((tool) => allowedReadTools.has(tool.name));
|
||||
const mutationTools = mutationScope
|
||||
? [
|
||||
createEditToolDefinition(request.cwd, {
|
||||
operations: {
|
||||
async readFile(absolutePath) {
|
||||
return readFile(await mutationScope.assertWritePath(absolutePath));
|
||||
},
|
||||
async writeFile(absolutePath, content) {
|
||||
await mutationScope.writeFile(absolutePath, content);
|
||||
},
|
||||
async access(absolutePath) {
|
||||
await access(await mutationScope.assertWritePath(absolutePath));
|
||||
},
|
||||
},
|
||||
}),
|
||||
createWriteToolDefinition(request.cwd, {
|
||||
operations: {
|
||||
async writeFile(absolutePath, content) {
|
||||
await mutationScope.writeFile(absolutePath, content);
|
||||
},
|
||||
async mkdir(directory) {
|
||||
await mutationScope.mkdir(directory);
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const removalTools =
|
||||
mutationScope && request.role === "remediate"
|
||||
? [
|
||||
defineTool({
|
||||
name: "workflow_remove",
|
||||
label: "Remove Owned Prototype Path",
|
||||
description:
|
||||
"Remove a prototype-only file or directory inside Assignment ownership.",
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
async execute(_id, params) {
|
||||
await mutationScope.remove(params.path);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Removed ${params.path}` },
|
||||
],
|
||||
details: { path: params.path },
|
||||
};
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return this.scheduler.schedule({
|
||||
runId: request.runId,
|
||||
runLimit: request.runConcurrency ?? this.options.runConcurrency,
|
||||
signal: request.signal,
|
||||
exclusive: request.mutates,
|
||||
onReserved: request.onReserved,
|
||||
task: async (signal) => {
|
||||
let created: AssignmentExecutionMetadata | undefined;
|
||||
const gitBefore = gitProbe
|
||||
? await gitProbe.capture(mutationScope?.root ?? request.cwd)
|
||||
: undefined;
|
||||
const outcome = await execute({
|
||||
prompt: assignmentPrompt(request),
|
||||
schema: schemaForRole(request.role),
|
||||
model,
|
||||
thinkingLevel,
|
||||
cwd: request.cwd,
|
||||
loader: resources.loader,
|
||||
settingsManager: resources.settingsManager,
|
||||
modelRegistry: this.options.modelRegistry,
|
||||
signal,
|
||||
tools: request.mutates
|
||||
? [
|
||||
...(request.profile.tools ?? []).filter((tool) =>
|
||||
(["edit", "write"].includes(tool) ||
|
||||
allowedReadTools.has(tool)),
|
||||
),
|
||||
...evidenceTools.map((tool) => tool.name),
|
||||
...removalTools.map((tool) => tool.name),
|
||||
"structured_output",
|
||||
]
|
||||
: [
|
||||
...(request.profile.tools ?? []).filter((tool) =>
|
||||
allowedReadTools.has(tool),
|
||||
),
|
||||
...evidenceTools.map((tool) => tool.name),
|
||||
"structured_output",
|
||||
],
|
||||
customTools: [
|
||||
...auditedReadTools,
|
||||
...mutationTools,
|
||||
...removalTools,
|
||||
...evidenceTools,
|
||||
],
|
||||
onSessionCreated(metadata) {
|
||||
created = {
|
||||
childSessionId: metadata.childSessionId,
|
||||
provider: metadata.provider,
|
||||
model: metadata.model,
|
||||
effort: metadata.thinkingLevel,
|
||||
contextCapacity: undefined,
|
||||
transcript: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
const gitAfter = gitProbe
|
||||
? await gitProbe.capture(mutationScope?.root ?? request.cwd)
|
||||
: undefined;
|
||||
let safetyError: Error | undefined;
|
||||
if (
|
||||
request.role === "review" &&
|
||||
request.reviewDiff !== undefined &&
|
||||
!readCompletePagedText(
|
||||
outcome.transcript,
|
||||
"workflow_diff",
|
||||
[...request.reviewDiff].length,
|
||||
)
|
||||
) {
|
||||
safetyError = new Error(
|
||||
"Review Assignment did not read the complete workflow diff",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!safetyError &&
|
||||
request.approvedPlan !== undefined &&
|
||||
(request.role === "verify" || request.role === "review") &&
|
||||
!readCompletePagedText(
|
||||
outcome.transcript,
|
||||
"workflow_plan",
|
||||
[...request.approvedPlan].length,
|
||||
)
|
||||
) {
|
||||
safetyError = new Error(
|
||||
"Assignment did not read the complete approved workflow plan",
|
||||
);
|
||||
}
|
||||
if (!safetyError && gitProbe && gitBefore && gitAfter) {
|
||||
try {
|
||||
gitProbe.assertIdentity(gitBefore, gitAfter);
|
||||
const touched = gitProbe.changedSince(gitBefore, gitAfter);
|
||||
const outOfScope = mutationScope
|
||||
? touched.filter((changed) => !mutationScope.owns(changed))
|
||||
: touched;
|
||||
if (outOfScope.length > 0) {
|
||||
throw new Error(
|
||||
request.captureGitState && !mutationScope
|
||||
? `Read-only Assignment changed the repository: ${outOfScope.join(", ")}`
|
||||
: `Assignment changed paths outside ownership: ${outOfScope.join(", ")}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
safetyError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
const metadata: AssignmentExecutionMetadata = {
|
||||
childSessionId: outcome.childSessionId ?? created?.childSessionId,
|
||||
provider: outcome.provider ?? created?.provider,
|
||||
model: outcome.model ?? created?.model,
|
||||
effort: outcome.thinkingLevel ?? created?.effort ?? thinkingLevel,
|
||||
contextCapacity: outcome.contextWindow,
|
||||
transcript: outcome.transcript,
|
||||
gitBefore,
|
||||
gitAfter,
|
||||
};
|
||||
if (safetyError || !outcome.ok) {
|
||||
const error = new Error(
|
||||
safetyError?.message ?? outcome.error ?? "Pi Assignment failed",
|
||||
) as AssignmentExecutionError;
|
||||
error.assignmentMetadata = metadata;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
kind: "workflow-assignment-result",
|
||||
output: outcome.structured,
|
||||
metadata,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { registerWorkflowRecovery } from "./index.ts";
|
||||
import type { ContextTransitionRuntime } from "./context-transition.ts";
|
||||
|
||||
|
||||
test("session startup invokes persisted Workflow recovery", async () => {
|
||||
let sessionStart:
|
||||
| ((event: { reason: string }, ctx: ExtensionContext) => Promise<void>)
|
||||
| undefined;
|
||||
const pi = {
|
||||
on(event: string, handler: typeof sessionStart) {
|
||||
if (event === "session_start") sessionStart = handler;
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
let recoveries = 0;
|
||||
registerWorkflowRecovery(pi, {
|
||||
store: {} as never,
|
||||
engineForContext: async () =>
|
||||
({
|
||||
async recover() {
|
||||
recoveries++;
|
||||
return [];
|
||||
},
|
||||
}) as never,
|
||||
} as ContextTransitionRuntime);
|
||||
assert.ok(sessionStart);
|
||||
await sessionStart(
|
||||
{ reason: "startup" },
|
||||
{
|
||||
ui: { notify: () => {} },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
assert.equal(recoveries, 1);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
getAgentDir,
|
||||
type ExtensionAPI,
|
||||
type ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { loadWorkflowConfig } from "./config.ts";
|
||||
import type { WorkflowRun } from "./domain.ts";
|
||||
import { openWorkflowDashboard } from "./dashboard.ts";
|
||||
import {
|
||||
registerWorkflowContextTransitions,
|
||||
type ContextTransitionRuntime,
|
||||
} from "./context-transition.ts";
|
||||
import { WorkflowEngine } from "./engine.ts";
|
||||
import {
|
||||
PiAssignmentExecutor,
|
||||
type ProcessAssignmentScheduler,
|
||||
} from "./execution.ts";
|
||||
import { loadAgentProfiles } from "./profiles.ts";
|
||||
import {
|
||||
reconcileWorkflowDeliveries,
|
||||
registerWorkflowNotifications,
|
||||
} from "./notifications.ts";
|
||||
import { runScriptSandbox } from "./script-runtime.ts";
|
||||
import { WorkflowStore } from "./store.ts";
|
||||
import { registerWorkflowTool } from "./tool.ts";
|
||||
import {
|
||||
refreshWorkflowProgress,
|
||||
registerWorkflowProgress,
|
||||
} from "./progress.ts";
|
||||
import {
|
||||
ApprovedHostCommandExecutor,
|
||||
FilesystemPrototypeValidator,
|
||||
GitDiffProvider,
|
||||
GitWorkspaceManager,
|
||||
} from "./workspace.ts";
|
||||
import type { ThinkingLevel, WorkflowModel } from "../_workflows/runner.ts";
|
||||
|
||||
export interface WorkflowExtensionOptions {
|
||||
stateRoot?: string;
|
||||
scheduler?: ProcessAssignmentScheduler;
|
||||
afterRunPersist?: (
|
||||
ctx: ExtensionContext,
|
||||
run: WorkflowRun,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createWorkflowRuntime(
|
||||
options: WorkflowExtensionOptions = {},
|
||||
): ContextTransitionRuntime {
|
||||
const store = new WorkflowStore(
|
||||
options.stateRoot ?? path.join(getAgentDir(), "workflows"),
|
||||
);
|
||||
return {
|
||||
store,
|
||||
async engineForContext(ctx: ExtensionContext) {
|
||||
const config = await loadWorkflowConfig({
|
||||
cwd: ctx.cwd,
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
});
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: ctx.cwd,
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
standardAgents: config.standardAgents,
|
||||
});
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
parentCwd: ctx.cwd,
|
||||
parentProjectTrusted: ctx.isProjectTrusted(),
|
||||
parentModel: ctx.model as WorkflowModel | undefined,
|
||||
parentThinkingLevel: (ctx.thinkingLevel ?? "off") as ThinkingLevel,
|
||||
concurrencyCeiling: config.concurrency.ceiling,
|
||||
runConcurrency: config.concurrency.default,
|
||||
...(options.scheduler ? { scheduler: options.scheduler } : {}),
|
||||
});
|
||||
return new WorkflowEngine({
|
||||
store,
|
||||
workspace: new GitWorkspaceManager(),
|
||||
executor,
|
||||
hostCommands: new ApprovedHostCommandExecutor(),
|
||||
reviewDiff: new GitDiffProvider(),
|
||||
scriptRuntime: { run: runScriptSandbox },
|
||||
prototypeValidator: new FilesystemPrototypeValidator(),
|
||||
sourceRoot: ctx.cwd,
|
||||
parentSessionId: ctx.sessionManager.getSessionId(),
|
||||
effectiveConfig: config,
|
||||
profiles: catalog.profiles,
|
||||
...(options.afterRunPersist
|
||||
? {
|
||||
afterRunPersist: (run) => options.afterRunPersist!(ctx, run),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerWorkflowRecovery(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
try {
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
await engine.recover();
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`workflow startup recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function workflowExtension(pi: ExtensionAPI) {
|
||||
let runtime: ContextTransitionRuntime;
|
||||
runtime = createWorkflowRuntime({
|
||||
afterRunPersist: async (ctx) => {
|
||||
await Promise.all([
|
||||
reconcileWorkflowDeliveries(pi, runtime, ctx),
|
||||
refreshWorkflowProgress(runtime, ctx),
|
||||
]);
|
||||
},
|
||||
});
|
||||
registerWorkflowContextTransitions(pi, runtime, (args, ctx) =>
|
||||
openWorkflowDashboard(pi, runtime, ctx, args || undefined),
|
||||
);
|
||||
registerWorkflowTool(pi, runtime);
|
||||
registerWorkflowRecovery(pi, runtime);
|
||||
registerWorkflowNotifications(pi, runtime);
|
||||
registerWorkflowProgress(pi, runtime);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import {
|
||||
reconcileWorkflowDeliveries,
|
||||
workflowNotificationType,
|
||||
} from "./notifications.ts";
|
||||
import { WorkflowStore } from "./store.ts";
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-delivery-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function workflow(background = false): WorkflowRun {
|
||||
const gateId = "gate-1";
|
||||
return {
|
||||
runId: "run-1",
|
||||
revision: 0,
|
||||
mode: "feature",
|
||||
status: "waiting",
|
||||
background,
|
||||
request: "Build search",
|
||||
featureName: "search",
|
||||
parentSessionIds: ["session-1"],
|
||||
lease: {
|
||||
processId: 100,
|
||||
activeParentSessionId: "session-1",
|
||||
heartbeatAt: 1,
|
||||
},
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/run-1",
|
||||
branch: "feature/search",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
pinnedProfiles: [],
|
||||
preflightEligibility: [],
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["search"],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
stages: [],
|
||||
gates: [
|
||||
{
|
||||
id: gateId,
|
||||
kind: "plan-approval",
|
||||
status: "open",
|
||||
revision: 0,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
assignments: [],
|
||||
prototypeCandidates: [],
|
||||
prototypeCleanupCompleted: false,
|
||||
deliveries: [
|
||||
{
|
||||
key: `gate:run-1:${gateId}:0`,
|
||||
state: "pending",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
commandReceipts: [],
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function deliveryHarness(entries: unknown[], persistImmediately = true) {
|
||||
const sent: Array<{ message: unknown; options: unknown }> = [];
|
||||
const pi = {
|
||||
sendMessage(message: unknown, options: unknown) {
|
||||
sent.push({ message, options });
|
||||
if (persistImmediately) {
|
||||
const record = message as Record<string, unknown>;
|
||||
entries.push({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "custom",
|
||||
customType: record.customType,
|
||||
details: record.details,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
} as unknown as Pick<ExtensionAPI, "sendMessage">;
|
||||
const ctx = {
|
||||
sessionManager: {
|
||||
getSessionId: () => "session-1",
|
||||
getEntries: () => entries,
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
return { pi, ctx, sent };
|
||||
}
|
||||
|
||||
test("delivery receipt closes the send/save crash window exactly once", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
await store.createRun(workflow());
|
||||
const entries: unknown[] = [];
|
||||
const harness = deliveryHarness(entries);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
const message = harness.sent[0]?.message as Record<string, unknown>;
|
||||
assert.equal(message.customType, workflowNotificationType);
|
||||
assert.match(String(message.content), /workflow search is waiting/);
|
||||
assert.equal(
|
||||
(message.details as Record<string, unknown>).runId,
|
||||
"run-1",
|
||||
);
|
||||
const delivered = await store.loadRun("run-1");
|
||||
assert.equal(delivered.deliveries[0]?.state, "sent");
|
||||
assert.equal(delivered.revision, 0);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("queued foreground delivery suppresses immediate duplicates and retries a missing receipt", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const originalNow = Date.now;
|
||||
let now = 1_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow();
|
||||
await store.createRun(run);
|
||||
const entries: unknown[] = [];
|
||||
const harness = deliveryHarness(entries, false);
|
||||
const runtime = {
|
||||
store,
|
||||
engineForContext: () => Promise.reject(new Error("unused")),
|
||||
};
|
||||
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
||||
now++;
|
||||
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.deepEqual(harness.sent[0]?.options, { deliverAs: "steer" });
|
||||
now += 30_000;
|
||||
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
const sentMessage = harness.sent[1]?.message as Record<string, unknown>;
|
||||
entries.push({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "custom",
|
||||
customType: sentMessage.customType,
|
||||
details: sentMessage.details,
|
||||
},
|
||||
});
|
||||
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
||||
assert.equal(harness.sent.length, 2);
|
||||
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("obsolete Gate deliveries settle without sending contradictory notices", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow();
|
||||
run.status = "running";
|
||||
run.gates[0]!.status = "satisfied";
|
||||
await store.createRun(run);
|
||||
const harness = deliveryHarness([]);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(harness.sent.length, 0);
|
||||
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
||||
});
|
||||
});
|
||||
|
||||
test("an existing receiver receipt marks pending delivery without resending", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow(true);
|
||||
await store.createRun(run);
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "custom",
|
||||
customType: workflowNotificationType,
|
||||
details: { deliveryKey: run.deliveries[0]?.key },
|
||||
},
|
||||
},
|
||||
];
|
||||
const harness = deliveryHarness(entries);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(harness.sent.length, 0);
|
||||
assert.equal(
|
||||
(await store.loadRun("run-1")).deliveries[0]?.state,
|
||||
"sent",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal deliveries include the persisted report and settle once", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow(true);
|
||||
run.status = "completed";
|
||||
run.gates = [];
|
||||
run.reportArtifact = {
|
||||
path: "/state/report.md",
|
||||
digest: "digest",
|
||||
revision: 4,
|
||||
};
|
||||
run.deliveries = [
|
||||
{
|
||||
key: "terminal:run-1:completed:4",
|
||||
state: "pending",
|
||||
createdAt: 4,
|
||||
},
|
||||
];
|
||||
await store.createRun(run);
|
||||
const harness = deliveryHarness([]);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.equal(harness.sent.length, 1);
|
||||
assert.match(
|
||||
String((harness.sent[0]?.message as Record<string, unknown>).content),
|
||||
/workflow search finished with status completed.*Report: \/state\/report.md/,
|
||||
);
|
||||
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
||||
});
|
||||
});
|
||||
|
||||
test("background deliveries request a follow-up turn", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
await store.createRun(workflow(true));
|
||||
const harness = deliveryHarness([]);
|
||||
await reconcileWorkflowDeliveries(
|
||||
harness.pi,
|
||||
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
||||
harness.ctx,
|
||||
);
|
||||
assert.deepEqual(harness.sent[0]?.options, {
|
||||
deliverAs: "followUp",
|
||||
triggerTurn: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { WorkflowRun } from "./domain.ts";
|
||||
import type { ContextTransitionRuntime } from "./context-transition.ts";
|
||||
import { deriveRunLabel } from "./run-label.ts";
|
||||
|
||||
const NOTIFICATION_TYPE = "workflow-notification";
|
||||
const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`;
|
||||
const ACTIVE_DELIVERIES = new Map<
|
||||
string,
|
||||
{ scheduledAt: number; runId: string }
|
||||
>();
|
||||
const DELIVERY_RETRY_MS = 30_000;
|
||||
|
||||
function receiptKeys(ctx: ExtensionContext) {
|
||||
const keys = new Set<string>();
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const message = entry.message as unknown as Record<string, unknown>;
|
||||
if (message.role !== "custom" || message.customType !== NOTIFICATION_TYPE) {
|
||||
continue;
|
||||
}
|
||||
const details = message.details;
|
||||
if (!details || typeof details !== "object") continue;
|
||||
const key = (details as Record<string, unknown>).deliveryKey;
|
||||
if (typeof key === "string") keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function deliveryIsCurrent(run: WorkflowRun, key: string) {
|
||||
if (key.startsWith("gate:")) {
|
||||
const gate = [...run.gates]
|
||||
.reverse()
|
||||
.find((candidate) => key.includes(`:${candidate.id}:`));
|
||||
const activeGate = [...run.gates]
|
||||
.reverse()
|
||||
.find((candidate) => candidate.status === "open");
|
||||
return Boolean(gate && gate.status === "open" && activeGate?.id === gate.id);
|
||||
}
|
||||
if (key.startsWith("terminal:")) {
|
||||
return key.includes(`:${run.status}:`) &&
|
||||
(run.status === "completed" ||
|
||||
run.status === "failed" ||
|
||||
run.status === "aborted");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function deliveryText(run: WorkflowRun, key: string) {
|
||||
const label = deriveRunLabel(run);
|
||||
if (key.startsWith("gate:")) {
|
||||
const gate = [...run.gates]
|
||||
.reverse()
|
||||
.find((candidate) => key.includes(`:${candidate.id}:`));
|
||||
return gate
|
||||
? `workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
|
||||
: `workflow ${label} is waiting for a Gate Decision.`;
|
||||
}
|
||||
const report = run.reportArtifact?.path
|
||||
? ` Report: ${run.reportArtifact.path}`
|
||||
: "";
|
||||
return `workflow ${label} finished with status ${run.status}.${report}`;
|
||||
}
|
||||
|
||||
function belongsToSession(run: WorkflowRun, sessionId: string) {
|
||||
const activeSession =
|
||||
run.lease?.activeParentSessionId ?? run.parentSessionIds.at(-1);
|
||||
return activeSession === sessionId;
|
||||
}
|
||||
|
||||
export async function reconcileWorkflowDeliveries(
|
||||
pi: Pick<ExtensionAPI, "sendMessage">,
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionContext,
|
||||
) {
|
||||
const sessionId = ctx.sessionManager.getSessionId();
|
||||
if (!sessionId) return;
|
||||
let receipts = receiptKeys(ctx);
|
||||
for (const run of await runtime.store.listRuns()) {
|
||||
if (!belongsToSession(run, sessionId)) continue;
|
||||
if (
|
||||
(run.status === "completed" ||
|
||||
run.status === "failed" ||
|
||||
run.status === "aborted") &&
|
||||
run.reportArtifact
|
||||
) {
|
||||
await runtime.store.ensureResult(run);
|
||||
}
|
||||
for (const delivery of run.deliveries) {
|
||||
if (delivery.state === "sent") continue;
|
||||
if (!deliveryIsCurrent(run, delivery.key)) {
|
||||
ACTIVE_DELIVERIES.delete(delivery.key);
|
||||
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
||||
await runtime.store.markDeliverySent(
|
||||
run.runId,
|
||||
delivery.key,
|
||||
Date.now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (receipts.has(delivery.key)) {
|
||||
ACTIVE_DELIVERIES.delete(delivery.key);
|
||||
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
||||
await runtime.store.markDeliverySent(
|
||||
run.runId,
|
||||
delivery.key,
|
||||
Date.now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const active = ACTIVE_DELIVERIES.get(delivery.key);
|
||||
if (
|
||||
active &&
|
||||
Date.now() - active.scheduledAt < DELIVERY_RETRY_MS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const claimedAt = Date.now();
|
||||
const claimed = await runtime.store.claimDelivery(
|
||||
run.runId,
|
||||
delivery.key,
|
||||
DELIVERY_OWNER_TOKEN,
|
||||
claimedAt,
|
||||
);
|
||||
if (!claimed) continue;
|
||||
ACTIVE_DELIVERIES.set(delivery.key, {
|
||||
scheduledAt: claimedAt,
|
||||
runId: run.runId,
|
||||
});
|
||||
try {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: NOTIFICATION_TYPE,
|
||||
content: deliveryText(run, delivery.key),
|
||||
display: true,
|
||||
details: {
|
||||
deliveryKey: delivery.key,
|
||||
runId: run.runId,
|
||||
revision: run.revision,
|
||||
status: run.status,
|
||||
reportPath: run.reportArtifact?.path,
|
||||
},
|
||||
},
|
||||
run.background
|
||||
? { deliverAs: "followUp", triggerTurn: true }
|
||||
: { deliverAs: "steer" },
|
||||
);
|
||||
receipts = receiptKeys(ctx);
|
||||
if (receipts.has(delivery.key)) {
|
||||
ACTIVE_DELIVERIES.delete(delivery.key);
|
||||
await runtime.store.clearDeliveryClaim(run.runId, delivery.key);
|
||||
await runtime.store.markDeliverySent(
|
||||
run.runId,
|
||||
delivery.key,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
ACTIVE_DELIVERIES.delete(delivery.key);
|
||||
await runtime.store.releaseDeliveryClaim(
|
||||
run.runId,
|
||||
delivery.key,
|
||||
DELIVERY_OWNER_TOKEN,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorkflowNotifications(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
) {
|
||||
const reconcile = async (ctx: ExtensionContext) => {
|
||||
try {
|
||||
await reconcileWorkflowDeliveries(pi, runtime, ctx);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`workflow delivery recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
};
|
||||
pi.on("session_start", async (_event, ctx) => reconcile(ctx));
|
||||
pi.on("agent_settled", async (_event, ctx) => reconcile(ctx));
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
if (event.toolName === "workflow") await reconcile(ctx);
|
||||
});
|
||||
pi.on("session_shutdown", async () => {
|
||||
const active = [...ACTIVE_DELIVERIES.entries()];
|
||||
ACTIVE_DELIVERIES.clear();
|
||||
await Promise.all(
|
||||
active.map(([key, delivery]) =>
|
||||
runtime.store.releaseDeliveryClaim(
|
||||
delivery.runId,
|
||||
key,
|
||||
DELIVERY_OWNER_TOKEN,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const workflowNotificationType = NOTIFICATION_TYPE;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@_jb/pi-workflow",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Private workspace for the deferred Pi workflow extension migration.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": { "node": ">=22.19.0" },
|
||||
"keywords": ["pi-package", "pi-extension", "workflow"],
|
||||
"pi": { "extensions": ["./index.ts"] },
|
||||
"dependencies": {
|
||||
"@_jb/pi-shared": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "catalog:pi-peer",
|
||||
"@earendil-works/pi-coding-agent": "catalog:pi-peer",
|
||||
"@earendil-works/pi-tui": "catalog:pi-peer",
|
||||
"typebox": "catalog:pi-peer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import type { AgentProfile, WorkflowFacts } from "./domain.ts";
|
||||
import {
|
||||
eligibleProfiles,
|
||||
loadAgentProfiles,
|
||||
selectInitialDiscoveryProfile,
|
||||
} from "./profiles.ts";
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-profiles-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function profileDocument(options: {
|
||||
name: string;
|
||||
description?: string;
|
||||
roles?: string;
|
||||
selection?: string;
|
||||
extra?: string;
|
||||
body?: string;
|
||||
}) {
|
||||
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\nroles: ${options.roles ?? "[discover]"}\n${options.extra ?? ""}selection:\n${options.selection ?? " stages: [discover]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n"}---\n\n${options.body ?? "Follow the assignment."}\n`;
|
||||
}
|
||||
|
||||
function standardAgentDocument(options: {
|
||||
name: string;
|
||||
description?: string;
|
||||
model?: string;
|
||||
tools?: string;
|
||||
thinking?: string;
|
||||
body?: string;
|
||||
}) {
|
||||
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\n${options.model ? `model: ${options.model}\n` : ""}${options.tools ? `tools: ${options.tools}\n` : ""}${options.thinking ? `thinking: ${options.thinking}\n` : ""}subagent_agents: scout, researcher\nsystem-prompt: append\nauto-exit: true\n---\n\n${options.body ?? "Follow the standard assignment."}\n`;
|
||||
}
|
||||
|
||||
async function writeProfile(
|
||||
root: string,
|
||||
file: string,
|
||||
options: Parameters<typeof profileDocument>[0],
|
||||
) {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(path.join(root, file), profileDocument(options));
|
||||
}
|
||||
|
||||
async function writeStandardAgent(
|
||||
root: string,
|
||||
file: string,
|
||||
options: Parameters<typeof standardAgentDocument>[0],
|
||||
) {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(path.join(root, file), standardAgentDocument(options));
|
||||
}
|
||||
|
||||
test("bundled profiles form a complete deterministic catalog", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
catalog.profiles.map((profile) => profile.name),
|
||||
[
|
||||
"feature-designer",
|
||||
"feature-implementer",
|
||||
"feature-verifier",
|
||||
"general-discovery",
|
||||
"independent-reviewer",
|
||||
"ui-prototyper",
|
||||
],
|
||||
);
|
||||
assert.equal(catalog.diagnostics.length, 0);
|
||||
assert.ok(
|
||||
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("standard package, global, and trusted project agents use role overlays and precedence", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const agentDir = path.join(directory, "agent");
|
||||
const globalRoot = path.join(agentDir, "agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", {
|
||||
name: "general-discovery",
|
||||
});
|
||||
await writeStandardAgent(packageRoot, "scout.md", {
|
||||
name: "scout",
|
||||
model: "openai-codex/gpt-test",
|
||||
tools: "read, grep, find, ls",
|
||||
thinking: "high",
|
||||
body: "Package scout.",
|
||||
});
|
||||
await writeStandardAgent(packageRoot, "worker.md", {
|
||||
name: "worker",
|
||||
tools: "read, write, edit, bash",
|
||||
body: "Package worker.",
|
||||
});
|
||||
await writeStandardAgent(globalRoot, "scout.md", {
|
||||
name: "scout",
|
||||
body: "Global scout.",
|
||||
});
|
||||
await writeStandardAgent(globalRoot, "unmapped.md", {
|
||||
name: "unmapped",
|
||||
});
|
||||
await writeStandardAgent(projectRoot, "worker.md", {
|
||||
name: "worker",
|
||||
body: "Project worker.",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { scout: ["discover"], worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
|
||||
const scout = catalog.profiles.find((profile) => profile.name === "scout");
|
||||
assert.equal(scout?.source, "standard-global");
|
||||
assert.equal(scout?.instructions, "Global scout.");
|
||||
assert.deepEqual(scout?.roles, ["discover"]);
|
||||
assert.deepEqual(scout?.selection.stages, ["discover"]);
|
||||
|
||||
const worker = catalog.profiles.find((profile) => profile.name === "worker");
|
||||
assert.equal(worker?.source, "standard-project");
|
||||
assert.equal(worker?.instructions, "Project worker.");
|
||||
assert.deepEqual(worker?.roles, ["implement"]);
|
||||
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("package and global standard definitions cannot self-declare workflow roles", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeProfile(packageRoot, "worker.md", {
|
||||
name: "worker",
|
||||
roles: "[review]",
|
||||
selection:
|
||||
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
body: "Attempted self-declared reviewer.",
|
||||
});
|
||||
await writeProfile(packageRoot, "unmapped.md", {
|
||||
name: "unmapped",
|
||||
roles: "[review]",
|
||||
selection:
|
||||
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectTrusted: false,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
const worker = catalog.profiles.find((profile) => profile.name === "worker");
|
||||
assert.equal(worker?.source, "standard-package");
|
||||
assert.deepEqual(worker?.roles, ["implement"]);
|
||||
assert.deepEqual(worker?.selection.stages, ["implement"]);
|
||||
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a trusted workflow profile keeps its declared roles when its name also has a standard overlay", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
|
||||
await writeProfile(projectRoot, "scout.md", {
|
||||
name: "scout",
|
||||
roles: "[verify]",
|
||||
selection:
|
||||
" stages: [verify]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
body: "Project verifier.",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { scout: ["discover"] },
|
||||
},
|
||||
});
|
||||
const scout = catalog.profiles.find((profile) => profile.name === "scout");
|
||||
assert.equal(scout?.source, "project");
|
||||
assert.deepEqual(scout?.roles, ["verify"]);
|
||||
assert.deepEqual(scout?.selection.stages, ["verify"]);
|
||||
assert.equal(scout?.instructions, "Project verifier.");
|
||||
});
|
||||
});
|
||||
|
||||
test("standard agents are disabled explicitly and ignored for untrusted projects", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
|
||||
await writeStandardAgent(projectRoot, "worker.md", { name: "worker" });
|
||||
|
||||
const disabled = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: false,
|
||||
roles: { scout: ["discover"], worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(disabled.profiles.map((profile) => profile.name), ["general-discovery"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("trusted project profiles override bundled profiles while first duplicate wins", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const project = path.join(directory, "project");
|
||||
await writeProfile(bundled, "10-shared.md", {
|
||||
name: "shared",
|
||||
description: "bundled first",
|
||||
});
|
||||
await writeProfile(bundled, "20-shared.md", {
|
||||
name: "shared",
|
||||
description: "bundled duplicate",
|
||||
});
|
||||
await writeProfile(project, "10-shared.md", {
|
||||
name: "shared",
|
||||
description: "project override",
|
||||
});
|
||||
await writeFile(path.join(project, "20-broken.md"), "not frontmatter");
|
||||
|
||||
const untrusted = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectRoot: project,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.equal(untrusted.profiles[0]?.description, "bundled first");
|
||||
assert.equal(untrusted.diagnostics.length, 1);
|
||||
assert.doesNotMatch(untrusted.diagnostics[0]?.sourcePath ?? "", /project/);
|
||||
|
||||
const trusted = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectRoot: project,
|
||||
projectTrusted: true,
|
||||
});
|
||||
assert.equal(trusted.profiles[0]?.description, "project override");
|
||||
assert.equal(trusted.profiles[0]?.source, "project");
|
||||
assert.ok(
|
||||
trusted.diagnostics.some((item) =>
|
||||
/duplicate profile/.test(item.message),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
trusted.diagnostics.some((item) =>
|
||||
/missing YAML frontmatter/.test(item.message),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed profiles are isolated and unknown fields produce warnings", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
await writeProfile(bundled, "good.md", {
|
||||
name: "good-profile",
|
||||
description: "Coordinates https://example.test: path",
|
||||
roles: "\n - discover",
|
||||
extra:
|
||||
"unknownField: ignored\nmetadata:\n owner: team\nlabels:\n - internal\n",
|
||||
selection:
|
||||
" stages:\n - discover\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n unknownSelection:\n owner: team\n",
|
||||
});
|
||||
await writeFile(
|
||||
path.join(bundled, "quoted.md"),
|
||||
String.raw`---
|
||||
name: quoted-profile
|
||||
description: "Path C:\\" # comment
|
||||
roles: [discover]
|
||||
selection:
|
||||
stages: [discover]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: ["path\\", dashboard] # comment
|
||||
---
|
||||
|
||||
Quoted values remain valid.
|
||||
`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(bundled, "single.md"),
|
||||
String.raw`---
|
||||
name: single-profile
|
||||
description: 'Path C:\' # comment
|
||||
roles: [discover]
|
||||
selection:
|
||||
stages: [discover]
|
||||
anyTags: []
|
||||
allTags: []
|
||||
pathGlobs: []
|
||||
keywords: ['path\', dashboard] # comment
|
||||
---
|
||||
|
||||
Single-quoted values keep literal backslashes.
|
||||
`,
|
||||
);
|
||||
await writeProfile(bundled, "bad-provider.md", {
|
||||
name: "bad-provider",
|
||||
extra: "model: provider/model\nprovider: provider\n",
|
||||
});
|
||||
await writeProfile(bundled, "bad-name.md", { name: "Bad Name" });
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
catalog.profiles.map((profile) => profile.name),
|
||||
["good-profile", "quoted-profile", "single-profile"],
|
||||
);
|
||||
assert.equal(
|
||||
catalog.profiles[0]?.description,
|
||||
"Coordinates https://example.test: path",
|
||||
);
|
||||
assert.deepEqual(catalog.profiles[1]?.selection.keywords, [
|
||||
"path\\",
|
||||
"dashboard",
|
||||
]);
|
||||
assert.deepEqual(catalog.profiles[2]?.selection.keywords, [
|
||||
"path\\",
|
||||
"dashboard",
|
||||
]);
|
||||
assert.ok(
|
||||
catalog.diagnostics.some(
|
||||
(item) =>
|
||||
item.severity === "warning" && /unknown profile/.test(item.message),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.diagnostics.some((item) =>
|
||||
/unknown selection/.test(item.message),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.diagnostics.some((item) =>
|
||||
/provider requires/.test(item.message),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.diagnostics.some((item) => /lowercase kebab/.test(item.message)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a symlinked profile root is rejected", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const actual = path.join(directory, "actual");
|
||||
const linked = path.join(directory, "linked");
|
||||
await writeProfile(actual, "agent.md", { name: "external-root" });
|
||||
await symlink(actual, linked);
|
||||
await assert.rejects(
|
||||
loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: linked,
|
||||
projectTrusted: false,
|
||||
}),
|
||||
/profile root must not be a symlink/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a symlinked project profile parent is rejected", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const repository = path.join(directory, "repository");
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const externalPi = path.join(directory, "external-pi");
|
||||
await mkdir(repository, { recursive: true });
|
||||
await writeProfile(bundled, "agent.md", { name: "bundled-agent" });
|
||||
await writeProfile(path.join(externalPi, "agents"), "agent.md", {
|
||||
name: "external-project",
|
||||
});
|
||||
await symlink(externalPi, path.join(repository, ".pi"));
|
||||
|
||||
await assert.rejects(
|
||||
loadAgentProfiles({
|
||||
cwd: repository,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: true,
|
||||
}),
|
||||
/project profile ancestry must not be a symlink/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("profile files and symlinks must resolve inside their source root", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const outside = path.join(directory, "outside.md");
|
||||
await mkdir(bundled, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(bundled, "internal.profile"),
|
||||
profileDocument({ name: "internal-link" }),
|
||||
);
|
||||
await writeFile(outside, profileDocument({ name: "outside-link" }));
|
||||
await symlink("internal.profile", path.join(bundled, "internal.md"));
|
||||
await symlink(outside, path.join(bundled, "outside.md"));
|
||||
await symlink("missing.profile", path.join(bundled, "broken.md"));
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
catalog.profiles.map((profile) => profile.name),
|
||||
["internal-link"],
|
||||
);
|
||||
assert.ok(catalog.profiles[0]?.resolvedPath.endsWith("internal.profile"));
|
||||
assert.ok(
|
||||
catalog.diagnostics.some((item) =>
|
||||
/escapes the profile root/.test(item.message),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
catalog.diagnostics.some((item) => item.sourcePath.endsWith("broken.md")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("the nearest trusted ancestor project profile root is selected", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const project = path.join(directory, ".pi", "agents");
|
||||
const nestedCwd = path.join(directory, "packages", "app");
|
||||
await mkdir(nestedCwd, { recursive: true });
|
||||
await writeProfile(bundled, "agent.md", {
|
||||
name: "shared-agent",
|
||||
description: "bundled",
|
||||
});
|
||||
await writeProfile(project, "agent.md", {
|
||||
name: "shared-agent",
|
||||
description: "nearest project",
|
||||
});
|
||||
|
||||
const trusted = await loadAgentProfiles({
|
||||
cwd: nestedCwd,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: true,
|
||||
});
|
||||
assert.equal(trusted.profiles[0]?.description, "nearest project");
|
||||
|
||||
const untrusted = await loadAgentProfiles({
|
||||
cwd: nestedCwd,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.equal(untrusted.profiles[0]?.description, "bundled");
|
||||
});
|
||||
});
|
||||
|
||||
test("active profile contents are pinned by digest", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
await writeProfile(bundled, "agent.md", {
|
||||
name: "pinned-agent",
|
||||
body: "Original instructions.",
|
||||
});
|
||||
const first = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: false,
|
||||
});
|
||||
await writeProfile(bundled, "agent.md", {
|
||||
name: "pinned-agent",
|
||||
body: "Changed instructions.",
|
||||
});
|
||||
const second = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
bundledRoot: bundled,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.equal(first.profiles[0]?.instructions, "Original instructions.");
|
||||
assert.notEqual(first.profiles[0]?.digest, second.profiles[0]?.digest);
|
||||
});
|
||||
});
|
||||
|
||||
function agent(
|
||||
name: string,
|
||||
selection: AgentProfile["selection"],
|
||||
source: AgentProfile["source"] = "bundled",
|
||||
): AgentProfile {
|
||||
return {
|
||||
name,
|
||||
description: name,
|
||||
roles: ["discover"],
|
||||
selection,
|
||||
instructions: "Discover.",
|
||||
source,
|
||||
sourcePath: name,
|
||||
resolvedPath: name,
|
||||
digest: name.padEnd(64, "0"),
|
||||
};
|
||||
}
|
||||
|
||||
const baseFacts: Omit<WorkflowFacts, "stage"> = {
|
||||
taskKind: "feature",
|
||||
requestTerms: ["dashboard"],
|
||||
candidatePaths: ["src/routes/dashboard/page.tsx"],
|
||||
languages: ["typescript"],
|
||||
tags: ["frontend", "ui"],
|
||||
riskTags: [],
|
||||
};
|
||||
|
||||
test("eligibility records exclusion reasons and selects the most specific discovery profile", () => {
|
||||
const fallback = agent("general", {
|
||||
stages: ["discover"],
|
||||
anyTags: [],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
});
|
||||
const dashboard = agent("dashboard", {
|
||||
stages: ["discover"],
|
||||
anyTags: ["ui"],
|
||||
allTags: ["frontend"],
|
||||
pathGlobs: ["src/**/dashboard/*.tsx"],
|
||||
keywords: ["dashboard"],
|
||||
});
|
||||
const backend = agent("backend", {
|
||||
stages: ["discover"],
|
||||
anyTags: ["backend"],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
});
|
||||
|
||||
const result = selectInitialDiscoveryProfile(
|
||||
[fallback, dashboard, backend],
|
||||
baseFacts,
|
||||
);
|
||||
assert.equal(result.selected.name, "dashboard");
|
||||
const excluded = result.evaluations.find(
|
||||
(item) => item.profile.name === "backend",
|
||||
);
|
||||
assert.equal(excluded?.eligible, false);
|
||||
assert.deepEqual(excluded?.reasons, ["no optional tag matched"]);
|
||||
|
||||
const preDiscovery = eligibleProfiles(
|
||||
[fallback, dashboard],
|
||||
{
|
||||
...baseFacts,
|
||||
stage: "discover",
|
||||
requestTerms: [],
|
||||
candidatePaths: [],
|
||||
tags: [],
|
||||
},
|
||||
"discover",
|
||||
);
|
||||
assert.equal(
|
||||
preDiscovery.find((item) => item.eligible)?.profile.name,
|
||||
"general",
|
||||
);
|
||||
});
|
||||
|
||||
test("an eligible standard agent replaces the bundled fallback for its role", () => {
|
||||
const emptySelection: AgentProfile["selection"] = {
|
||||
stages: ["discover"],
|
||||
anyTags: [],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
};
|
||||
const bundled = agent("general-discovery", emptySelection);
|
||||
const scout = agent("scout", emptySelection, "standard-package");
|
||||
|
||||
const result = selectInitialDiscoveryProfile(
|
||||
[bundled, scout],
|
||||
baseFacts,
|
||||
);
|
||||
assert.equal(result.selected.name, "scout");
|
||||
assert.deepEqual(
|
||||
result.evaluations.find((item) => item.profile.name === "general-discovery")?.reasons,
|
||||
["eligible standard agent available for role discover"],
|
||||
);
|
||||
});
|
||||
|
||||
test("eligibility enforces every criterion and uses a code-point name tie-breaker", () => {
|
||||
const emptySelection: AgentProfile["selection"] = {
|
||||
stages: ["discover"],
|
||||
anyTags: [],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
};
|
||||
const wrongRole = {
|
||||
...agent("wrong-role", emptySelection),
|
||||
roles: ["design"] as AgentProfile["roles"],
|
||||
};
|
||||
const wrongStage = agent("wrong-stage", {
|
||||
...emptySelection,
|
||||
stages: ["design"],
|
||||
});
|
||||
const missingAllTag = agent("missing-all-tag", {
|
||||
...emptySelection,
|
||||
allTags: ["backend"],
|
||||
});
|
||||
const missingAnyTag = agent("missing-any-tag", {
|
||||
...emptySelection,
|
||||
anyTags: ["backend"],
|
||||
});
|
||||
const missingPath = agent("missing-path", {
|
||||
...emptySelection,
|
||||
pathGlobs: ["server/**/*.ts"],
|
||||
});
|
||||
const missingKeyword = agent("missing-keyword", {
|
||||
...emptySelection,
|
||||
keywords: ["api"],
|
||||
});
|
||||
|
||||
const evaluations = eligibleProfiles(
|
||||
[
|
||||
wrongRole,
|
||||
wrongStage,
|
||||
missingAllTag,
|
||||
missingAnyTag,
|
||||
missingPath,
|
||||
missingKeyword,
|
||||
],
|
||||
{ ...baseFacts, stage: "discover" },
|
||||
"discover",
|
||||
);
|
||||
const reasons = new Map(
|
||||
evaluations.map((evaluation) => [
|
||||
evaluation.profile.name,
|
||||
evaluation.reasons,
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(reasons.get("wrong-role"), [
|
||||
"does not support role discover",
|
||||
]);
|
||||
assert.deepEqual(reasons.get("wrong-stage"), [
|
||||
"does not support stage discover",
|
||||
]);
|
||||
assert.deepEqual(reasons.get("missing-all-tag"), [
|
||||
"missing required tags: backend",
|
||||
]);
|
||||
assert.deepEqual(reasons.get("missing-any-tag"), ["no optional tag matched"]);
|
||||
assert.deepEqual(reasons.get("missing-path"), ["no candidate path matched"]);
|
||||
assert.deepEqual(reasons.get("missing-keyword"), [
|
||||
"no request keyword matched",
|
||||
]);
|
||||
|
||||
const tie = eligibleProfiles(
|
||||
[agent("zeta", emptySelection), agent("alpha", emptySelection)],
|
||||
{ ...baseFacts, stage: "discover" },
|
||||
"discover",
|
||||
);
|
||||
assert.deepEqual(
|
||||
tie
|
||||
.filter((evaluation) => evaluation.eligible)
|
||||
.map((evaluation) => evaluation.profile.name),
|
||||
["alpha", "zeta"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,814 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
PROFILE_ROLES,
|
||||
REASONING_EFFORTS,
|
||||
type AgentProfile,
|
||||
type AssignmentStageKind,
|
||||
type EligibilityResult,
|
||||
type ProfileCatalog,
|
||||
type ProfileDiagnostic,
|
||||
type ProfileRole,
|
||||
type ProfileSelection,
|
||||
type StandardAgentConfig,
|
||||
type WorkflowFacts,
|
||||
} from "./domain.ts";
|
||||
|
||||
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const TOOL_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const TOP_LEVEL_FIELDS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"roles",
|
||||
"model",
|
||||
"provider",
|
||||
"effort",
|
||||
"tools",
|
||||
"selection",
|
||||
]);
|
||||
const SELECTION_FIELDS = new Set([
|
||||
"stages",
|
||||
"anyTags",
|
||||
"allTags",
|
||||
"pathGlobs",
|
||||
"keywords",
|
||||
]);
|
||||
|
||||
interface ParsedFrontmatter {
|
||||
values: Record<string, unknown>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface LoadSourceResult {
|
||||
profiles: AgentProfile[];
|
||||
diagnostics: ProfileDiagnostic[];
|
||||
}
|
||||
|
||||
type ProfileSourceMode = "workflow" | "standard" | "project";
|
||||
|
||||
export interface LoadAgentProfilesOptions {
|
||||
cwd: string;
|
||||
projectTrusted: boolean;
|
||||
agentDir?: string;
|
||||
bundledRoot?: string;
|
||||
standardPackageRoot?: string;
|
||||
projectRoot?: string;
|
||||
standardAgents?: StandardAgentConfig;
|
||||
}
|
||||
|
||||
function reason(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string) {
|
||||
if (left === right) return 0;
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
function isEscaped(value: string, index: number) {
|
||||
let backslashes = 0;
|
||||
for (
|
||||
let cursor = index - 1;
|
||||
cursor >= 0 && value[cursor] === "\\";
|
||||
cursor--
|
||||
) {
|
||||
backslashes++;
|
||||
}
|
||||
return backslashes % 2 === 1;
|
||||
}
|
||||
|
||||
function stripComment(value: string) {
|
||||
let quote: "'" | '"' | undefined;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (quote) {
|
||||
if (quote === "'" && character === "'" && value[index + 1] === "'") {
|
||||
index++;
|
||||
} else if (
|
||||
character === quote &&
|
||||
(quote === "'" || !isEscaped(value, index))
|
||||
) {
|
||||
quote = undefined;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
} else if (
|
||||
character === "#" &&
|
||||
(index === 0 || /\s/.test(value[index - 1] ?? ""))
|
||||
) {
|
||||
return value.slice(0, index).trimEnd();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function splitFlowItems(value: string, label: string) {
|
||||
const items: string[] = [];
|
||||
let quote: "'" | '"' | undefined;
|
||||
let start = 0;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const character = value[index];
|
||||
if (quote) {
|
||||
if (quote === "'" && character === "'" && value[index + 1] === "'") {
|
||||
index++;
|
||||
} else if (
|
||||
character === quote &&
|
||||
(quote === "'" || !isEscaped(value, index))
|
||||
) {
|
||||
quote = undefined;
|
||||
}
|
||||
} else if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
} else if (character === ",") {
|
||||
items.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
if (quote) throw new Error(`${label}: unterminated quoted value`);
|
||||
items.push(value.slice(start));
|
||||
return items;
|
||||
}
|
||||
|
||||
function scalar(value: string, label: string) {
|
||||
const trimmed = stripComment(value).trim();
|
||||
if (trimmed.length === 0) throw new Error(`${label}: value cannot be empty`);
|
||||
if (trimmed.startsWith('"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (typeof parsed !== "string") throw new Error("not a string");
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
throw new Error(`${label}: invalid quoted string`, { cause: error });
|
||||
}
|
||||
}
|
||||
if (trimmed.startsWith("'")) {
|
||||
if (!trimmed.endsWith("'") || trimmed.length < 2) {
|
||||
throw new Error(`${label}: invalid quoted string`);
|
||||
}
|
||||
return trimmed.slice(1, -1).replaceAll("''", "'");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function yamlValue(value: string, label: string): string | string[] {
|
||||
const trimmed = stripComment(value).trim();
|
||||
if (!trimmed.startsWith("[")) return scalar(trimmed, label);
|
||||
if (!trimmed.endsWith("]")) throw new Error(`${label}: invalid flow array`);
|
||||
const inside = trimmed.slice(1, -1).trim();
|
||||
if (inside.length === 0) return [];
|
||||
return splitFlowItems(inside, label).map((entry, index) =>
|
||||
scalar(entry, `${label}[${index}]`),
|
||||
);
|
||||
}
|
||||
|
||||
function parseFrontmatter(
|
||||
contents: string,
|
||||
sourcePath: string,
|
||||
): ParsedFrontmatter {
|
||||
const normalized = contents.replaceAll("\r\n", "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines[0] !== "---") throw new Error("missing YAML frontmatter");
|
||||
const closing = lines.indexOf("---", 1);
|
||||
if (closing < 0) throw new Error("unterminated YAML frontmatter");
|
||||
|
||||
const values: Record<string, unknown> = {};
|
||||
let section: Record<string, unknown> | undefined;
|
||||
let sectionName: string | undefined;
|
||||
let skippedUnknownIndent: number | undefined;
|
||||
let pendingList:
|
||||
| { container: Record<string, unknown>; key: string; indent: number }
|
||||
| undefined;
|
||||
|
||||
for (let lineIndex = 1; lineIndex < closing; lineIndex++) {
|
||||
const raw = lines[lineIndex] ?? "";
|
||||
if (raw.trim().length === 0 || raw.trimStart().startsWith("#")) continue;
|
||||
if (raw.includes("\t"))
|
||||
throw new Error(
|
||||
`frontmatter line ${lineIndex + 1}: tabs are not supported`,
|
||||
);
|
||||
const indent = raw.length - raw.trimStart().length;
|
||||
if (skippedUnknownIndent !== undefined) {
|
||||
if (indent > skippedUnknownIndent) continue;
|
||||
skippedUnknownIndent = undefined;
|
||||
}
|
||||
if (indent % 2 !== 0) {
|
||||
throw new Error(
|
||||
`frontmatter line ${lineIndex + 1}: indentation must use pairs of spaces`,
|
||||
);
|
||||
}
|
||||
const text = stripComment(raw.trim());
|
||||
if (text.length === 0) continue;
|
||||
|
||||
if (text.startsWith("- ")) {
|
||||
if (!pendingList || indent !== pendingList.indent + 2) {
|
||||
throw new Error(
|
||||
`frontmatter line ${lineIndex + 1}: unexpected list item`,
|
||||
);
|
||||
}
|
||||
const list = pendingList.container[pendingList.key];
|
||||
if (!Array.isArray(list))
|
||||
throw new Error(`${sourcePath}: invalid list state`);
|
||||
list.push(scalar(text.slice(2), `frontmatter line ${lineIndex + 1}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingList = undefined;
|
||||
const separator = text.indexOf(":");
|
||||
if (separator < 1)
|
||||
throw new Error(`frontmatter line ${lineIndex + 1}: expected key: value`);
|
||||
const key = text.slice(0, separator).trim();
|
||||
const rawValue = text.slice(separator + 1).trim();
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) {
|
||||
throw new Error(
|
||||
`frontmatter line ${lineIndex + 1}: invalid key ${JSON.stringify(key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let container: Record<string, unknown>;
|
||||
if (indent === 0) {
|
||||
container = values;
|
||||
section = undefined;
|
||||
sectionName = undefined;
|
||||
} else if (indent === 2 && section && sectionName) {
|
||||
container = section;
|
||||
} else {
|
||||
throw new Error(`frontmatter line ${lineIndex + 1}: unsupported nesting`);
|
||||
}
|
||||
if (Object.hasOwn(container, key)) {
|
||||
throw new Error(
|
||||
`frontmatter line ${lineIndex + 1}: duplicate field ${key}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rawValue.length > 0) {
|
||||
container[key] = yamlValue(rawValue, `frontmatter field ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const knownField =
|
||||
indent === 0 ? TOP_LEVEL_FIELDS.has(key) : SELECTION_FIELDS.has(key);
|
||||
if (!knownField) {
|
||||
container[key] = "<ignored nested value>";
|
||||
skippedUnknownIndent = indent;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent === 0 && key === "selection") {
|
||||
section = {};
|
||||
sectionName = key;
|
||||
values[key] = section;
|
||||
} else {
|
||||
container[key] = [];
|
||||
pendingList = { container, key, indent };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
body: lines
|
||||
.slice(closing + 1)
|
||||
.join("\n")
|
||||
.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${field} must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string) {
|
||||
return value === undefined ? undefined : requiredString(value, field);
|
||||
}
|
||||
|
||||
function stringList(value: unknown, field: string, allowCsv = false) {
|
||||
const list =
|
||||
allowCsv && typeof value === "string"
|
||||
? value.split(",").map((entry) => entry.trim())
|
||||
: value;
|
||||
if (!Array.isArray(list) || list.some((entry) => typeof entry !== "string")) {
|
||||
throw new Error(
|
||||
`${field} must be an array of strings${allowCsv ? " or CSV string" : ""}`,
|
||||
);
|
||||
}
|
||||
const normalized = list.map((entry) => entry.trim());
|
||||
if (normalized.some((entry) => entry.length === 0)) {
|
||||
throw new Error(`${field} cannot contain empty values`);
|
||||
}
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new Error(`${field} cannot contain duplicate values`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function enumList<T extends string>(
|
||||
value: unknown,
|
||||
field: string,
|
||||
allowed: readonly T[],
|
||||
): T[] {
|
||||
const entries = stringList(value, field);
|
||||
const invalid = entries.filter((entry) => !allowed.includes(entry as T));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${field} contains unsupported values: ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return entries as T[];
|
||||
}
|
||||
|
||||
function parseSelection(value: unknown) {
|
||||
if (!isRecord(value)) throw new Error("selection must be an object");
|
||||
const warnings = Object.keys(value)
|
||||
.filter((key) => !SELECTION_FIELDS.has(key))
|
||||
.map((key) => `unknown selection field ${key}`);
|
||||
const stages = enumList(value.stages, "selection.stages", PROFILE_ROLES);
|
||||
if (stages.length === 0)
|
||||
throw new Error("selection.stages must not be empty");
|
||||
const normalize = (entries: string[]) =>
|
||||
entries.map((entry) => entry.toLowerCase());
|
||||
const selection: ProfileSelection = {
|
||||
stages,
|
||||
anyTags: normalize(stringList(value.anyTags ?? [], "selection.anyTags")),
|
||||
allTags: normalize(stringList(value.allTags ?? [], "selection.allTags")),
|
||||
pathGlobs: stringList(value.pathGlobs ?? [], "selection.pathGlobs").map(
|
||||
(entry) => entry.replaceAll("\\", "/"),
|
||||
),
|
||||
keywords: normalize(stringList(value.keywords ?? [], "selection.keywords")),
|
||||
};
|
||||
return { selection, warnings };
|
||||
}
|
||||
|
||||
function buildStandardProfile(
|
||||
parsed: ParsedFrontmatter,
|
||||
contents: string,
|
||||
sourcePath: string,
|
||||
resolvedPath: string,
|
||||
source: AgentProfile["source"],
|
||||
fallbackName: string,
|
||||
roles: ProfileRole[],
|
||||
) {
|
||||
const name = optionalString(parsed.values.name, "name") ?? fallbackName;
|
||||
if (!PROFILE_NAME.test(name)) {
|
||||
throw new Error("name must be lowercase kebab case");
|
||||
}
|
||||
const description =
|
||||
optionalString(parsed.values.description, "description") ?? name;
|
||||
const model = optionalString(parsed.values.model, "model");
|
||||
const effort = optionalString(parsed.values.thinking, "thinking");
|
||||
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
|
||||
throw new Error(`thinking must be one of ${REASONING_EFFORTS.join(", ")}`);
|
||||
}
|
||||
const tools =
|
||||
parsed.values.tools === undefined
|
||||
? undefined
|
||||
: stringList(parsed.values.tools, "tools", true);
|
||||
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
|
||||
throw new Error("tools contains an invalid tool name");
|
||||
}
|
||||
if (parsed.body.length === 0) {
|
||||
throw new Error("agent instructions must not be empty");
|
||||
}
|
||||
const selection: ProfileSelection = {
|
||||
stages: [...roles],
|
||||
anyTags: [],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
};
|
||||
const digestInput = `${contents}\nworkflow-role-overlay:${JSON.stringify(roles)}`;
|
||||
const profile: AgentProfile = {
|
||||
name,
|
||||
description,
|
||||
roles: [...roles],
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
selection,
|
||||
instructions: parsed.body,
|
||||
source,
|
||||
sourcePath,
|
||||
resolvedPath,
|
||||
digest: createHash("sha256").update(digestInput).digest("hex"),
|
||||
};
|
||||
return { profile, warnings: [] as string[] };
|
||||
}
|
||||
|
||||
function buildProfile(
|
||||
contents: string,
|
||||
sourcePath: string,
|
||||
resolvedPath: string,
|
||||
source: AgentProfile["source"],
|
||||
) {
|
||||
const parsed = parseFrontmatter(contents, sourcePath);
|
||||
const warnings = Object.keys(parsed.values)
|
||||
.filter((key) => !TOP_LEVEL_FIELDS.has(key))
|
||||
.map((key) => `unknown profile field ${key}`);
|
||||
|
||||
const name = requiredString(parsed.values.name, "name");
|
||||
if (!PROFILE_NAME.test(name)) {
|
||||
throw new Error("name must be lowercase kebab case");
|
||||
}
|
||||
const description = requiredString(parsed.values.description, "description");
|
||||
const roles = enumList(
|
||||
parsed.values.roles,
|
||||
"roles",
|
||||
PROFILE_ROLES,
|
||||
) as ProfileRole[];
|
||||
if (roles.length === 0) throw new Error("roles must not be empty");
|
||||
const model = optionalString(parsed.values.model, "model");
|
||||
const provider = optionalString(parsed.values.provider, "provider");
|
||||
if (provider && (!model || model.includes("/"))) {
|
||||
throw new Error("provider requires a bare model field");
|
||||
}
|
||||
const effort = optionalString(parsed.values.effort, "effort");
|
||||
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
|
||||
throw new Error(`effort must be one of ${REASONING_EFFORTS.join(", ")}`);
|
||||
}
|
||||
const tools =
|
||||
parsed.values.tools === undefined
|
||||
? undefined
|
||||
: stringList(parsed.values.tools, "tools", true);
|
||||
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
|
||||
throw new Error("tools contains an invalid tool name");
|
||||
}
|
||||
if (parsed.body.length === 0)
|
||||
throw new Error("agent instructions must not be empty");
|
||||
const { selection, warnings: selectionWarnings } = parseSelection(
|
||||
parsed.values.selection,
|
||||
);
|
||||
|
||||
const profile: AgentProfile = {
|
||||
name,
|
||||
description,
|
||||
roles,
|
||||
...(model ? { model } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
selection,
|
||||
instructions: parsed.body,
|
||||
source,
|
||||
sourcePath,
|
||||
resolvedPath,
|
||||
digest: createHash("sha256").update(contents).digest("hex"),
|
||||
};
|
||||
return { profile, warnings: [...warnings, ...selectionWarnings] };
|
||||
}
|
||||
|
||||
function isInside(parent: string, child: string) {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
relative === "" ||
|
||||
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSource(
|
||||
root: string,
|
||||
source: AgentProfile["source"],
|
||||
required: boolean,
|
||||
mode: ProfileSourceMode = "workflow",
|
||||
standardRoles?: Record<string, ProfileRole[]>,
|
||||
): Promise<LoadSourceResult> {
|
||||
const diagnostics: ProfileDiagnostic[] = [];
|
||||
let resolvedRoot: string;
|
||||
try {
|
||||
const rootInfo = await lstat(root);
|
||||
if (rootInfo.isSymbolicLink()) {
|
||||
throw new Error("profile root must not be a symlink");
|
||||
}
|
||||
if (!rootInfo.isDirectory()) {
|
||||
throw new Error("profile root must be a directory");
|
||||
}
|
||||
resolvedRoot = await realpath(root);
|
||||
} catch (error) {
|
||||
if (!required && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { profiles: [], diagnostics };
|
||||
}
|
||||
throw new Error(
|
||||
`${root}: unable to resolve profile root: ${reason(error)}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(`${root}: unable to read profile root`, { cause: error });
|
||||
}
|
||||
|
||||
const profiles: AgentProfile[] = [];
|
||||
const names = new Set<string>();
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
compareText(left.name, right.name),
|
||||
)) {
|
||||
if (
|
||||
!entry.name.endsWith(".md") ||
|
||||
(!entry.isFile() && !entry.isSymbolicLink())
|
||||
)
|
||||
continue;
|
||||
const sourcePath = path.join(root, entry.name);
|
||||
try {
|
||||
const resolvedPath = await realpath(sourcePath);
|
||||
if (!isInside(resolvedRoot, resolvedPath)) {
|
||||
throw new Error("resolved path escapes the profile root");
|
||||
}
|
||||
if (!(await stat(resolvedPath)).isFile()) {
|
||||
throw new Error("profile is not a regular file");
|
||||
}
|
||||
const contents = await readFile(resolvedPath, "utf8");
|
||||
const parsed = parseFrontmatter(contents, sourcePath);
|
||||
const declaredName =
|
||||
optionalString(parsed.values.name, "name") ??
|
||||
entry.name.replace(/\.md$/, "");
|
||||
const isStandardDefinition =
|
||||
mode === "standard" ||
|
||||
(mode === "project" && parsed.values.roles === undefined);
|
||||
let built;
|
||||
if (isStandardDefinition) {
|
||||
const roles = standardRoles?.[declaredName];
|
||||
if (!roles) continue;
|
||||
built = buildStandardProfile(
|
||||
parsed,
|
||||
contents,
|
||||
sourcePath,
|
||||
resolvedPath,
|
||||
source === "project" ? "standard-project" : source,
|
||||
entry.name.replace(/\.md$/, ""),
|
||||
roles,
|
||||
);
|
||||
} else {
|
||||
built = buildProfile(contents, sourcePath, resolvedPath, source);
|
||||
}
|
||||
const { profile, warnings } = built;
|
||||
for (const message of warnings) {
|
||||
diagnostics.push({ sourcePath, severity: "warning", message });
|
||||
}
|
||||
if (names.has(profile.name)) {
|
||||
diagnostics.push({
|
||||
sourcePath,
|
||||
severity: "error",
|
||||
message: `duplicate profile name ${profile.name}; first lexical file wins`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
names.add(profile.name);
|
||||
profiles.push(profile);
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
sourcePath,
|
||||
severity: "error",
|
||||
message: reason(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { profiles, diagnostics };
|
||||
}
|
||||
|
||||
async function assertProjectProfileAncestry(root: string) {
|
||||
const parent = path.dirname(root);
|
||||
const parentInfo = await lstat(parent);
|
||||
if (parentInfo.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`${parent}: project profile ancestry must not be a symlink`,
|
||||
);
|
||||
}
|
||||
if (!parentInfo.isDirectory()) {
|
||||
throw new Error(`${parent}: project profile parent must be a directory`);
|
||||
}
|
||||
}
|
||||
|
||||
async function findNearestProjectRoot(cwd: string) {
|
||||
let current = path.resolve(cwd);
|
||||
while (true) {
|
||||
const candidate = path.join(current, ".pi", "agents");
|
||||
try {
|
||||
await lstat(candidate);
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw new Error(`${candidate}: unable to inspect profile root`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAgentProfiles(
|
||||
options: LoadAgentProfilesOptions,
|
||||
): Promise<ProfileCatalog> {
|
||||
const bundledRoot =
|
||||
options.bundledRoot ?? path.join(import.meta.dirname, "agents");
|
||||
const bundled = await loadSource(bundledRoot, "bundled", true);
|
||||
const standardAgents = options.standardAgents ?? {
|
||||
enabled: false,
|
||||
roles: {},
|
||||
} satisfies StandardAgentConfig;
|
||||
const agentDir = options.agentDir ?? getAgentDir();
|
||||
const standardPackageRoot =
|
||||
options.standardPackageRoot ??
|
||||
path.join(
|
||||
agentDir,
|
||||
"extensions",
|
||||
"pi-interactive-subagents",
|
||||
"agents",
|
||||
);
|
||||
const standardPackage = standardAgents.enabled
|
||||
? await loadSource(
|
||||
standardPackageRoot,
|
||||
"standard-package",
|
||||
false,
|
||||
"standard",
|
||||
standardAgents.roles,
|
||||
)
|
||||
: { profiles: [], diagnostics: [] };
|
||||
const standardGlobal = standardAgents.enabled
|
||||
? await loadSource(
|
||||
path.join(agentDir, "agents"),
|
||||
"standard-global",
|
||||
false,
|
||||
"standard",
|
||||
standardAgents.roles,
|
||||
)
|
||||
: { profiles: [], diagnostics: [] };
|
||||
const projectRoot = options.projectTrusted
|
||||
? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd)))
|
||||
: undefined;
|
||||
if (projectRoot) await assertProjectProfileAncestry(projectRoot);
|
||||
const project = projectRoot
|
||||
? await loadSource(
|
||||
projectRoot,
|
||||
"project",
|
||||
false,
|
||||
"project",
|
||||
standardAgents.enabled ? standardAgents.roles : undefined,
|
||||
)
|
||||
: { profiles: [], diagnostics: [] };
|
||||
|
||||
const byName = new Map(
|
||||
bundled.profiles.map((profile) => [profile.name, profile]),
|
||||
);
|
||||
for (const profile of standardPackage.profiles) {
|
||||
byName.set(profile.name, profile);
|
||||
}
|
||||
for (const profile of standardGlobal.profiles) {
|
||||
byName.set(profile.name, profile);
|
||||
}
|
||||
for (const profile of project.profiles) {
|
||||
byName.set(profile.name, profile);
|
||||
}
|
||||
return {
|
||||
profiles: [...byName.values()].sort((left, right) =>
|
||||
compareText(left.name, right.name),
|
||||
),
|
||||
diagnostics: [
|
||||
...bundled.diagnostics,
|
||||
...standardPackage.diagnostics,
|
||||
...standardGlobal.diagnostics,
|
||||
...project.diagnostics,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function globPattern(pattern: string) {
|
||||
let expression = "^";
|
||||
for (let index = 0; index < pattern.length; index++) {
|
||||
const character = pattern[index] ?? "";
|
||||
if (character === "*" && pattern[index + 1] === "*") {
|
||||
if (pattern[index + 2] === "/") {
|
||||
expression += "(?:.*/)?";
|
||||
index += 2;
|
||||
} else {
|
||||
expression += ".*";
|
||||
index++;
|
||||
}
|
||||
} else if (character === "*") {
|
||||
expression += "[^/]*";
|
||||
} else if (character === "?") {
|
||||
expression += "[^/]";
|
||||
} else {
|
||||
expression += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
return new RegExp(`${expression}$`);
|
||||
}
|
||||
|
||||
export function evaluateProfileEligibility(
|
||||
profile: AgentProfile,
|
||||
facts: WorkflowFacts,
|
||||
role: ProfileRole,
|
||||
): EligibilityResult {
|
||||
const reasons: string[] = [];
|
||||
const tags = new Set(facts.tags.map((tag) => tag.toLowerCase()));
|
||||
const terms = new Set(facts.requestTerms.map((term) => term.toLowerCase()));
|
||||
const paths = facts.candidatePaths.map((candidate) =>
|
||||
candidate.replaceAll("\\", "/"),
|
||||
);
|
||||
|
||||
if (!profile.roles.includes(role))
|
||||
reasons.push(`does not support role ${role}`);
|
||||
if (!profile.selection.stages.includes(facts.stage)) {
|
||||
reasons.push(`does not support stage ${facts.stage}`);
|
||||
}
|
||||
const missingTags = profile.selection.allTags.filter((tag) => !tags.has(tag));
|
||||
if (missingTags.length > 0)
|
||||
reasons.push(`missing required tags: ${missingTags.join(", ")}`);
|
||||
if (
|
||||
profile.selection.anyTags.length > 0 &&
|
||||
!profile.selection.anyTags.some((tag) => tags.has(tag))
|
||||
) {
|
||||
reasons.push("no optional tag matched");
|
||||
}
|
||||
if (
|
||||
profile.selection.pathGlobs.length > 0 &&
|
||||
!profile.selection.pathGlobs.some((pattern) => {
|
||||
const matcher = globPattern(pattern);
|
||||
return paths.some((candidate) => matcher.test(candidate));
|
||||
})
|
||||
) {
|
||||
reasons.push("no candidate path matched");
|
||||
}
|
||||
if (
|
||||
profile.selection.keywords.length > 0 &&
|
||||
!profile.selection.keywords.some((keyword) => terms.has(keyword))
|
||||
) {
|
||||
reasons.push("no request keyword matched");
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
eligible: reasons.length === 0,
|
||||
reasons,
|
||||
specificity:
|
||||
profile.selection.allTags.length +
|
||||
profile.selection.anyTags.length +
|
||||
profile.selection.pathGlobs.length +
|
||||
profile.selection.keywords.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function eligibleProfiles(
|
||||
profiles: AgentProfile[],
|
||||
facts: WorkflowFacts,
|
||||
role: ProfileRole,
|
||||
) {
|
||||
const evaluations = profiles.map((profile) =>
|
||||
evaluateProfileEligibility(profile, facts, role),
|
||||
);
|
||||
const hasEligibleStandard = evaluations.some(
|
||||
(evaluation) =>
|
||||
evaluation.eligible && evaluation.profile.source.startsWith("standard-"),
|
||||
);
|
||||
const ranked = hasEligibleStandard
|
||||
? evaluations.map((evaluation) =>
|
||||
evaluation.eligible && evaluation.profile.source === "bundled"
|
||||
? {
|
||||
...evaluation,
|
||||
eligible: false,
|
||||
reasons: [
|
||||
...evaluation.reasons,
|
||||
`eligible standard agent available for role ${role}`,
|
||||
],
|
||||
}
|
||||
: evaluation,
|
||||
)
|
||||
: evaluations;
|
||||
return ranked.sort(
|
||||
(left, right) =>
|
||||
Number(right.eligible) - Number(left.eligible) ||
|
||||
right.specificity - left.specificity ||
|
||||
compareText(left.profile.name, right.profile.name),
|
||||
);
|
||||
}
|
||||
|
||||
export function selectInitialDiscoveryProfile(
|
||||
profiles: AgentProfile[],
|
||||
facts: Omit<WorkflowFacts, "stage">,
|
||||
) {
|
||||
const evaluations = eligibleProfiles(
|
||||
profiles,
|
||||
{ ...facts, stage: "discover" as AssignmentStageKind },
|
||||
"discover",
|
||||
);
|
||||
const selected = evaluations.find((evaluation) => evaluation.eligible);
|
||||
if (!selected)
|
||||
throw new Error("No eligible discovery Agent Profile is available");
|
||||
return { selected: selected.profile, evaluations };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
|
||||
import {
|
||||
projectWorkflowProgress,
|
||||
refreshWorkflowProgress,
|
||||
registerWorkflowProgress,
|
||||
renderWorkflowProgress,
|
||||
} from "./progress.ts";
|
||||
|
||||
function run(overrides: Partial<WorkflowRun> = {}): WorkflowRun {
|
||||
return {
|
||||
runId: "run-1",
|
||||
revision: 3,
|
||||
mode: "feature",
|
||||
status: "waiting",
|
||||
background: false,
|
||||
request: "Build dashboard",
|
||||
featureName: "dashboard",
|
||||
parentSessionIds: ["session-1"],
|
||||
lease: {
|
||||
processId: 1,
|
||||
activeParentSessionId: "session-1",
|
||||
heartbeatAt: 10,
|
||||
},
|
||||
workspace: { state: "ready", sourceRoot: "/repo" },
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
pinnedProfiles: [],
|
||||
preflightEligibility: [],
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: [],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
stages: [
|
||||
{ id: "d", kind: "discover", status: "completed" },
|
||||
{ id: "p", kind: "design", status: "waiting" },
|
||||
{ id: "pt", kind: "prototype", status: "pending" },
|
||||
{ id: "i", kind: "implement", status: "pending" },
|
||||
{ id: "v", kind: "verify", status: "pending" },
|
||||
{ id: "r", kind: "review", status: "pending" },
|
||||
],
|
||||
gates: [
|
||||
{
|
||||
id: "gate",
|
||||
kind: "plan-approval",
|
||||
status: "open",
|
||||
revision: 3,
|
||||
openedAt: 12,
|
||||
},
|
||||
],
|
||||
decisions: [],
|
||||
assignments: [],
|
||||
prototypeCandidates: [],
|
||||
prototypeCleanupCompleted: false,
|
||||
deliveries: [],
|
||||
commandReceipts: [],
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("progress projects the newest attached active run", () => {
|
||||
const older = run({ runId: "older", featureName: "older", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 5 } });
|
||||
const newest = run({ runId: "newest", featureName: "newest", lease: { processId: 1, activeParentSessionId: "session-1", heartbeatAt: 20 } });
|
||||
const terminal = run({ runId: "terminal", status: "completed", lease: undefined });
|
||||
const historical = run({ runId: "historical", parentSessionIds: ["session-2"] });
|
||||
const progress = projectWorkflowProgress([older, terminal, newest, historical], "session-1");
|
||||
assert.deepEqual(progress, {
|
||||
runId: "newest",
|
||||
label: "newest",
|
||||
stage: "DESIGN",
|
||||
completed: 1,
|
||||
total: 6,
|
||||
gate: "Plan Approval",
|
||||
});
|
||||
});
|
||||
|
||||
test("progress clears when no attached run remains active", () => {
|
||||
assert.equal(
|
||||
projectWorkflowProgress([run({ status: "failed", lease: undefined })], "session-1"),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(projectWorkflowProgress([run()], "session-2"), undefined);
|
||||
});
|
||||
|
||||
test("progress lifecycle refreshes from persistence and clears on terminal state and shutdown", async () => {
|
||||
let runs = [run()];
|
||||
let widget: unknown;
|
||||
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<void>>();
|
||||
const pi = {
|
||||
on(event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void>) {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
const runtime = {
|
||||
store: { listRuns: async () => runs },
|
||||
} as never;
|
||||
const ctx = {
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
setWidget(key: string, value: unknown) {
|
||||
assert.equal(key, "workflow-active-run");
|
||||
widget = value;
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
registerWorkflowProgress(pi, runtime);
|
||||
await handlers.get("session_start")?.({}, ctx);
|
||||
assert.equal(typeof widget, "function");
|
||||
|
||||
runs = [run({ status: "completed", lease: undefined })];
|
||||
await refreshWorkflowProgress(runtime, ctx);
|
||||
assert.equal(widget, undefined);
|
||||
|
||||
runs = [run()];
|
||||
await refreshWorkflowProgress(runtime, ctx);
|
||||
assert.equal(typeof widget, "function");
|
||||
await handlers.get("session_shutdown")?.({}, ctx);
|
||||
assert.equal(widget, undefined);
|
||||
});
|
||||
|
||||
test("progress rendering is one cell-width-safe line", () => {
|
||||
const progress = projectWorkflowProgress([run()], "session-1");
|
||||
assert.ok(progress);
|
||||
const wide = renderWorkflowProgress(progress, 80);
|
||||
assert.match(wide, /workflow dashboard DESIGN 1\/6 Gate: Plan Approval/);
|
||||
const narrow = renderWorkflowProgress(progress, 28);
|
||||
assert.ok(visibleWidth(narrow) <= 28);
|
||||
assert.equal(narrow.includes("\n"), false);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { truncateToWidth } from "@earendil-works/pi-tui";
|
||||
import type { ContextTransitionRuntime } from "./context-transition.ts";
|
||||
import type { WorkflowRun } from "./domain.ts";
|
||||
import { deriveRunLabels } from "./run-label.ts";
|
||||
|
||||
const WIDGET_KEY = "workflow-active-run";
|
||||
const CORE_STAGES = new Set([
|
||||
"discover",
|
||||
"design",
|
||||
"prototype",
|
||||
"implement",
|
||||
"verify",
|
||||
"review",
|
||||
]);
|
||||
|
||||
export interface WorkflowProgress {
|
||||
runId: string;
|
||||
label: string;
|
||||
stage: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
gate?: string;
|
||||
}
|
||||
|
||||
function activityTime(run: WorkflowRun) {
|
||||
return Math.max(
|
||||
run.lease?.heartbeatAt ?? 0,
|
||||
...run.gates.map((gate) => gate.openedAt),
|
||||
...run.stages.flatMap((stage) => [stage.startedAt ?? 0, stage.finishedAt ?? 0]),
|
||||
);
|
||||
}
|
||||
|
||||
function readableGate(kind: string) {
|
||||
return kind
|
||||
.split("-")
|
||||
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function newestAttachedActiveRun(
|
||||
runs: WorkflowRun[],
|
||||
sessionId: string | undefined,
|
||||
) {
|
||||
if (!sessionId) return undefined;
|
||||
return runs
|
||||
.filter(
|
||||
(run) =>
|
||||
(run.status === "running" || run.status === "waiting") &&
|
||||
run.parentSessionIds.includes(sessionId),
|
||||
)
|
||||
.sort((left, right) => activityTime(right) - activityTime(left))[0];
|
||||
}
|
||||
|
||||
export function projectWorkflowProgress(
|
||||
runs: WorkflowRun[],
|
||||
sessionId: string | undefined,
|
||||
): WorkflowProgress | undefined {
|
||||
const run = newestAttachedActiveRun(runs, sessionId);
|
||||
if (!run) return undefined;
|
||||
const active = [...run.stages]
|
||||
.reverse()
|
||||
.find((stage) => stage.status === "running" || stage.status === "waiting");
|
||||
const gate = [...run.gates].reverse().find((entry) => entry.status === "open");
|
||||
const label = deriveRunLabels(runs)[runs.indexOf(run)] ?? run.runId.slice(0, 8);
|
||||
const stages = run.stages.filter((stage) => CORE_STAGES.has(stage.kind));
|
||||
return {
|
||||
runId: run.runId,
|
||||
label,
|
||||
stage: String(active?.kind ?? run.status).toUpperCase(),
|
||||
completed: stages.filter((stage) => stage.status === "completed").length,
|
||||
total: stages.length,
|
||||
...(gate ? { gate: readableGate(gate.kind) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderWorkflowProgress(
|
||||
progress: WorkflowProgress,
|
||||
width: number,
|
||||
theme?: Pick<Theme, "fg">,
|
||||
) {
|
||||
const paint = (token: "accent" | "dim" | "muted" | "text", value: string) =>
|
||||
theme ? theme.fg(token, value) : value;
|
||||
const base = `${paint("accent", "workflow")} ${paint("text", progress.label)} ${paint("muted", progress.stage)} ${progress.completed}/${progress.total}`;
|
||||
const full = progress.gate ? `${base} ${paint("dim", `Gate: ${progress.gate}`)}` : base;
|
||||
return truncateToWidth(full, Math.max(1, width), "");
|
||||
}
|
||||
|
||||
export async function refreshWorkflowProgress(
|
||||
runtime: ContextTransitionRuntime,
|
||||
ctx: ExtensionContext,
|
||||
) {
|
||||
if (ctx.mode !== "tui") return;
|
||||
const progress = projectWorkflowProgress(
|
||||
await runtime.store.listRuns(),
|
||||
ctx.sessionManager.getSessionId(),
|
||||
);
|
||||
if (!progress) {
|
||||
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
||||
return;
|
||||
}
|
||||
ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
|
||||
render(width: number) {
|
||||
return [renderWorkflowProgress(progress, width, theme)];
|
||||
},
|
||||
invalidate() {},
|
||||
}));
|
||||
}
|
||||
|
||||
export function registerWorkflowProgress(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
await refreshWorkflowProgress(runtime, ctx);
|
||||
});
|
||||
pi.on("session_shutdown", async (_event, ctx) => {
|
||||
if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
|
||||
|
||||
test("run labels prefer feature names and normalize request fallbacks", () => {
|
||||
assert.equal(
|
||||
deriveRunLabel({
|
||||
runId: "12345678-aaaa",
|
||||
featureName: "search-page",
|
||||
request: "Ignored request",
|
||||
}),
|
||||
"search-page",
|
||||
);
|
||||
assert.equal(
|
||||
deriveRunLabel({
|
||||
runId: "12345678-bbbb",
|
||||
request: " Audit\n\tcache behavior ",
|
||||
}),
|
||||
"Audit cache behavior",
|
||||
);
|
||||
assert.equal(
|
||||
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(48) }),
|
||||
"x".repeat(48),
|
||||
);
|
||||
});
|
||||
|
||||
test("request labels truncate at whole words and hard-cut overlong words", () => {
|
||||
assert.equal(
|
||||
deriveRunLabel({
|
||||
runId: "12345678-aaaa",
|
||||
request: "Build a searchable dashboard that clearly identifies every active workflow run",
|
||||
}),
|
||||
"Build a searchable dashboard that clearly…",
|
||||
);
|
||||
assert.equal(
|
||||
deriveRunLabel({
|
||||
runId: "12345678-bbbb",
|
||||
request: `${"one ".repeat(11)}two continued`,
|
||||
}),
|
||||
`${"one ".repeat(11)}two…`,
|
||||
);
|
||||
assert.equal(
|
||||
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(60) }),
|
||||
`${"x".repeat(47)}…`,
|
||||
);
|
||||
});
|
||||
|
||||
test("run labels fall back to the canonical ID when requests are missing", () => {
|
||||
assert.equal(deriveRunLabel({ runId: "12345678-aaaa" }), "12345678-aaaa");
|
||||
assert.equal(
|
||||
deriveRunLabel({ runId: "12345678-bbbb", request: "" }),
|
||||
"12345678-bbbb",
|
||||
);
|
||||
});
|
||||
|
||||
test("run labels disambiguate collisions with extendable ID prefixes", () => {
|
||||
assert.deepEqual(
|
||||
deriveRunLabels([
|
||||
{ runId: "12345678-aaaa", featureName: "search-page" },
|
||||
{ runId: "12345678-bbbb", featureName: "search-page" },
|
||||
{ runId: "87654321-cccc", featureName: "other" },
|
||||
]),
|
||||
["search-page · 12345678-a", "search-page · 12345678-b", "other"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveRunLabels([
|
||||
{ runId: "aaaaaaaa-1111", request: "Audit cache" },
|
||||
{ runId: "bbbbbbbb-2222", request: "Audit cache" },
|
||||
]),
|
||||
["Audit cache · aaaaaaaa", "Audit cache · bbbbbbbb"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const MAX_REQUEST_LABEL_CHARACTERS = 48;
|
||||
const MIN_RUN_ID_PREFIX_CHARACTERS = 8;
|
||||
|
||||
export interface LabelledRun {
|
||||
runId: string;
|
||||
featureName?: string;
|
||||
request?: string;
|
||||
}
|
||||
|
||||
function requestRunLabel(request: string) {
|
||||
const normalized = request.trim().replace(/\s+/g, " ");
|
||||
const characters = [...normalized];
|
||||
if (characters.length <= MAX_REQUEST_LABEL_CHARACTERS) return normalized;
|
||||
const keptCharacters = characters.slice(0, MAX_REQUEST_LABEL_CHARACTERS - 1);
|
||||
const candidate = keptCharacters.join("");
|
||||
const nextCharacter = characters[MAX_REQUEST_LABEL_CHARACTERS - 1];
|
||||
const lastWhitespace = candidate.search(/\s+\S*$/);
|
||||
const bounded =
|
||||
nextCharacter !== undefined && /\s/.test(nextCharacter)
|
||||
? candidate.trimEnd()
|
||||
: lastWhitespace > 0
|
||||
? candidate.slice(0, lastWhitespace).trimEnd()
|
||||
: candidate;
|
||||
return `${bounded}…`;
|
||||
}
|
||||
|
||||
export function deriveRunLabel(run: LabelledRun) {
|
||||
const featureName = run.featureName?.trim();
|
||||
if (featureName) return featureName;
|
||||
const request = run.request?.trim();
|
||||
return request ? requestRunLabel(request) : run.runId;
|
||||
}
|
||||
|
||||
function uniqueRunIdPrefix(runId: string, peers: string[]) {
|
||||
const characters = [...runId];
|
||||
for (
|
||||
let length = Math.min(MIN_RUN_ID_PREFIX_CHARACTERS, characters.length);
|
||||
length < characters.length;
|
||||
length++
|
||||
) {
|
||||
const prefix = characters.slice(0, length).join("");
|
||||
if (peers.every((peer) => !peer.startsWith(prefix))) return prefix;
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
|
||||
/** Derives presentation labels and disambiguates only collisions in this list. */
|
||||
export function deriveRunLabels<T extends LabelledRun>(runs: readonly T[]) {
|
||||
const baseLabels = runs.map(deriveRunLabel);
|
||||
const collisions = new Map<string, number[]>();
|
||||
for (const [index, label] of baseLabels.entries()) {
|
||||
const indexes = collisions.get(label) ?? [];
|
||||
indexes.push(index);
|
||||
collisions.set(label, indexes);
|
||||
}
|
||||
return baseLabels.map((label, index) => {
|
||||
const indexes = collisions.get(label)!;
|
||||
if (indexes.length === 1) return label;
|
||||
const run = runs[index]!;
|
||||
const peers = indexes
|
||||
.filter((peerIndex) => peerIndex !== index)
|
||||
.map((peerIndex) => runs[peerIndex]!.runId);
|
||||
return `${label} · ${uniqueRunIdPrefix(run.runId, peers)}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
"use strict";
|
||||
|
||||
// This file is launched by sandbox.ts in Node permission mode. It deliberately
|
||||
// has no filesystem/network/child-process permissions and receives workflow
|
||||
// source only over a validated IPC channel.
|
||||
const vm = require("node:vm");
|
||||
const sendIpc =
|
||||
typeof process.send === "function" ? process.send.bind(process) : undefined;
|
||||
// If a future V8 escape exposes `process`, remove the convenient bridges to
|
||||
// builtins, native bindings, parent signalling, and addons before any workflow
|
||||
// source is compiled. The parent still enforces the authenticated IPC protocol.
|
||||
for (const capability of [
|
||||
"getBuiltinModule",
|
||||
"binding",
|
||||
"_linkedBinding",
|
||||
"dlopen",
|
||||
"kill",
|
||||
"abort",
|
||||
"send",
|
||||
]) {
|
||||
try {
|
||||
Object.defineProperty(process, capability, {
|
||||
value: undefined,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
} catch {
|
||||
// The VM boundary and permission mode remain mandatory controls.
|
||||
}
|
||||
}
|
||||
|
||||
const BOOTSTRAP = String.raw`
|
||||
(function bootstrapScriptApi() {
|
||||
"use strict";
|
||||
const callHost = globalThis.__hostBridge;
|
||||
delete globalThis.__hostBridge;
|
||||
let nextRequestId = 0;
|
||||
const unconsumed = new Set();
|
||||
const inFlight = new Set();
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
||||
Object.freeze(value);
|
||||
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestAssignment(roleValue, promptValue) {
|
||||
const id = ++nextRequestId;
|
||||
unconsumed.add(id);
|
||||
let started;
|
||||
const begin = () => {
|
||||
unconsumed.delete(id);
|
||||
if (!started) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.stringify({
|
||||
id,
|
||||
role: typeof roleValue === "string" ? roleValue : String(roleValue ?? ""),
|
||||
prompt: typeof promptValue === "string" ? promptValue : String(promptValue ?? ""),
|
||||
});
|
||||
} catch (error) {
|
||||
started = Promise.reject(new Error("assign() arguments must be serializable: " + error.message));
|
||||
return started;
|
||||
}
|
||||
inFlight.add(id);
|
||||
started = callHost("assign", payload)
|
||||
.then((json) => {
|
||||
const result = JSON.parse(json);
|
||||
if (result && result.ok === false) {
|
||||
throw new Error(result.error || "Script Assignment failed");
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => inFlight.delete(id));
|
||||
}
|
||||
return started;
|
||||
};
|
||||
return Object.freeze({
|
||||
then(resolve, reject) {
|
||||
return begin().then(resolve, reject);
|
||||
},
|
||||
catch(reject) {
|
||||
return begin().catch(reject);
|
||||
},
|
||||
finally(callback) {
|
||||
return begin().finally(callback);
|
||||
},
|
||||
get [Symbol.toStringTag]() {
|
||||
return "Promise";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function mapLimited(items, concurrency, invoke) {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (true) {
|
||||
const index = next++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await invoke(items[index]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parallel(items, options = {}) {
|
||||
if (!Array.isArray(items)) throw new Error("parallel() expects an array of zero-argument assignment thunks");
|
||||
const requested = options && typeof options.concurrency === "number"
|
||||
? Math.floor(options.concurrency)
|
||||
: 4;
|
||||
if (!Number.isFinite(requested) || requested < 1) {
|
||||
throw new Error("parallel(): concurrency must be a positive integer");
|
||||
}
|
||||
const concurrency = Math.min(4, requested);
|
||||
return mapLimited(items, concurrency, (item) => {
|
||||
if (typeof item !== "function") {
|
||||
throw new Error("parallel() items must be zero-argument functions");
|
||||
}
|
||||
return item();
|
||||
});
|
||||
}
|
||||
|
||||
function stage(title) {
|
||||
callHost("stage", JSON.stringify({ title: String(title) }));
|
||||
}
|
||||
|
||||
const argsEnvelope = JSON.parse(globalThis.__argsJson);
|
||||
const args = argsEnvelope.defined ? deepFreeze(argsEnvelope.value) : undefined;
|
||||
delete globalThis.__argsJson;
|
||||
const stringify = JSON.stringify;
|
||||
function serializeResult(value) {
|
||||
const seen = new WeakSet();
|
||||
return stringify(value === undefined ? null : value, (_key, item) => {
|
||||
if (typeof item === "bigint") return item.toString() + "n";
|
||||
if (item && typeof item === "object") {
|
||||
if (seen.has(item)) return "[circular]";
|
||||
seen.add(item);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
Object.defineProperties(globalThis, {
|
||||
assign: { value: requestAssignment, writable: false, configurable: false },
|
||||
parallel: { value: parallel, writable: false, configurable: false },
|
||||
stage: { value: stage, writable: false, configurable: false },
|
||||
args: { value: args, writable: false, configurable: false },
|
||||
__workflowCheck: {
|
||||
value: Object.freeze(() => ({
|
||||
unconsumed: unconsumed.size,
|
||||
inFlight: inFlight.size,
|
||||
})),
|
||||
writable: false,
|
||||
configurable: false,
|
||||
},
|
||||
__workflowSerialize: {
|
||||
value: Object.freeze(serializeResult),
|
||||
writable: false,
|
||||
configurable: false,
|
||||
},
|
||||
});
|
||||
})();
|
||||
`;
|
||||
|
||||
let initialized = false;
|
||||
let token;
|
||||
const pendingAssignments = new Map();
|
||||
|
||||
function send(message) {
|
||||
sendIpc?.({ token, ...message });
|
||||
}
|
||||
|
||||
function fail(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
send({ kind: "error", error: message.slice(0, 16 * 1024) });
|
||||
}
|
||||
|
||||
process.on("message", (message) => {
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (!initialized) {
|
||||
if (
|
||||
message.kind !== "init" ||
|
||||
typeof message.token !== "string" ||
|
||||
typeof message.source !== "string" ||
|
||||
typeof message.argsJson !== "string"
|
||||
) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
token = message.token;
|
||||
run(message.source, message.argsJson);
|
||||
return;
|
||||
}
|
||||
if (message.token !== token || message.kind !== "assignmentResult") return;
|
||||
const pending = pendingAssignments.get(message.id);
|
||||
if (!pending) return;
|
||||
pendingAssignments.delete(message.id);
|
||||
if (typeof message.resultJson === "string")
|
||||
pending.resolve(message.resultJson);
|
||||
else
|
||||
pending.reject(
|
||||
new Error(
|
||||
typeof message.error === "string" ? message.error : "Agent IPC failed",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function run(source, argsJson) {
|
||||
try {
|
||||
const sandbox = Object.create(null);
|
||||
sandbox.__argsJson = argsJson;
|
||||
sandbox.__hostBridge = (kind, payloadJson) => {
|
||||
if (kind === "stage") {
|
||||
send({ kind: "stage", payloadJson });
|
||||
return undefined;
|
||||
}
|
||||
if (kind !== "assign")
|
||||
return Promise.reject(new Error("Unknown workflow operation"));
|
||||
let id;
|
||||
try {
|
||||
id = JSON.parse(payloadJson).id;
|
||||
} catch {
|
||||
return Promise.reject(new Error("Invalid assign request"));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingAssignments.set(id, { resolve, reject });
|
||||
send({ kind: "assign", payloadJson });
|
||||
});
|
||||
};
|
||||
|
||||
const context = vm.createContext(sandbox, {
|
||||
name: "workflow",
|
||||
codeGeneration: { strings: false, wasm: false },
|
||||
});
|
||||
new vm.Script(BOOTSTRAP, {
|
||||
filename: "script-bootstrap.js",
|
||||
}).runInContext(context, { timeout: 1000 });
|
||||
const workflow = vm.compileFunction(
|
||||
`"use strict";\nreturn (async function workflow() {\n${source}\n})();`,
|
||||
["assign", "parallel", "stage", "args"],
|
||||
{ filename: "workflow-script.js", parsingContext: context },
|
||||
);
|
||||
context.__workflowBody = workflow;
|
||||
const invoke = `
|
||||
(() => {
|
||||
const workflowBody = globalThis.__workflowBody;
|
||||
delete globalThis.__workflowBody;
|
||||
globalThis.__workflowPromise = Promise.resolve(
|
||||
workflowBody(assign, parallel, stage, args),
|
||||
).then(async (value) => {
|
||||
await Promise.resolve();
|
||||
const pending = __workflowCheck();
|
||||
if (pending.unconsumed > 0) {
|
||||
throw new Error("Workflow created " + pending.unconsumed + " unawaited assign() call(s)");
|
||||
}
|
||||
if (pending.inFlight > 0) {
|
||||
throw new Error("Workflow returned before " + pending.inFlight + " assign call(s) settled");
|
||||
}
|
||||
return __workflowSerialize(value);
|
||||
});
|
||||
})();
|
||||
`;
|
||||
new vm.Script(invoke, { filename: "script-invoke.js" }).runInContext(
|
||||
context,
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
Promise.resolve(context.__workflowPromise)
|
||||
.then((resultJson) => {
|
||||
if (typeof resultJson !== "string")
|
||||
throw new Error("Workflow result was not serializable");
|
||||
send({ kind: "result", resultJson });
|
||||
})
|
||||
.catch(fail);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
parseScriptAssignmentPayload,
|
||||
runScriptSandbox,
|
||||
ScriptRequestBudget,
|
||||
validateScriptIpcEnvelope,
|
||||
} from "./script-runtime.ts";
|
||||
|
||||
function run(
|
||||
source: string,
|
||||
overrides: Partial<Parameters<typeof runScriptSandbox>[0]> = {},
|
||||
) {
|
||||
const abort = new AbortController();
|
||||
return runScriptSandbox({
|
||||
source,
|
||||
args: undefined,
|
||||
cwd: process.cwd(),
|
||||
signal: abort.signal,
|
||||
onAssign: async (role, prompt) => ({
|
||||
ok: true,
|
||||
output: `${role}:${prompt}`,
|
||||
}),
|
||||
onStage: () => {},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("script sandbox exposes only stage, assign, parallel, and immutable args", async () => {
|
||||
const stages: string[] = [];
|
||||
const result = await run(
|
||||
`
|
||||
stage("Audit");
|
||||
const replies = await parallel([
|
||||
() => assign("review", "one"),
|
||||
() => assign("review", "two"),
|
||||
], { concurrency: 99 });
|
||||
return {
|
||||
replies: replies.map((reply) => reply.output),
|
||||
processType: typeof process,
|
||||
requireType: typeof require,
|
||||
fetchType: typeof fetch,
|
||||
nestedArgsFrozen: Object.isFrozen(args.nested),
|
||||
};
|
||||
`,
|
||||
{
|
||||
args: { nested: { value: "immutable" } },
|
||||
onStage: (title) => stages.push(title),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
replies: ["review:one", "review:two"],
|
||||
processType: "undefined",
|
||||
requireType: "undefined",
|
||||
fetchType: "undefined",
|
||||
nestedArgsFrozen: true,
|
||||
});
|
||||
assert.deepEqual(stages, ["Audit"]);
|
||||
});
|
||||
|
||||
test("script sandbox rejects unawaited assignments and synchronous loops", async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
run(`assign("review", "orphan"); return "done";`, {
|
||||
onAssign: async () => {
|
||||
calls++;
|
||||
return { ok: true, output: "unexpected" };
|
||||
},
|
||||
}),
|
||||
/unawaited assign/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(run(`while (true) {}`), /timed out/);
|
||||
});
|
||||
|
||||
test("script cancellation aborts active assignments and terminates execution", async () => {
|
||||
const controller = new AbortController();
|
||||
let startedResolve: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
startedResolve = resolve;
|
||||
});
|
||||
let requestAborted = false;
|
||||
const pending = run(`return await assign("review", "pending");`, {
|
||||
signal: controller.signal,
|
||||
onAssign: async (_role, _prompt, signal) => {
|
||||
startedResolve?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
requestAborted = true;
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return { ok: false, output: "", error: "Assignment was aborted" };
|
||||
},
|
||||
});
|
||||
await started;
|
||||
controller.abort(new Error("cancel fixture"));
|
||||
await assert.rejects(pending, /Script was aborted/);
|
||||
assert.equal(requestAborted, true);
|
||||
});
|
||||
|
||||
test("script IPC rejects unauthenticated, malformed, oversized, duplicate, and over-budget requests", () => {
|
||||
assert.throws(
|
||||
() => validateScriptIpcEnvelope({ token: "wrong", kind: "assign" }, "right"),
|
||||
/invalid IPC message/,
|
||||
);
|
||||
assert.throws(() => parseScriptAssignmentPayload("{"), /malformed assignment JSON/);
|
||||
assert.throws(
|
||||
() => parseScriptAssignmentPayload("x".repeat(512 * 1024 + 1)),
|
||||
/oversized assignment request/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseScriptAssignmentPayload(JSON.stringify({ id: 1, role: "", prompt: "x" })),
|
||||
/invalid assignment request/,
|
||||
);
|
||||
const duplicate = new ScriptRequestBudget();
|
||||
duplicate.accept(1);
|
||||
assert.throws(() => duplicate.accept(1), /duplicate assignment request/);
|
||||
const budget = new ScriptRequestBudget();
|
||||
for (let id = 1; id <= 32; id++) budget.accept(id);
|
||||
assert.throws(() => budget.accept(33), /request budget/);
|
||||
});
|
||||
|
||||
test("script stage updates and total runtime are bounded", async () => {
|
||||
await assert.rejects(
|
||||
run(`for (let index = 0; index < 65; index++) stage("flood-" + index);`),
|
||||
/stage update budget/,
|
||||
);
|
||||
await assert.rejects(
|
||||
run(`while (true) { await Promise.resolve(); }`, { runtimeTimeoutMs: 20 }),
|
||||
/wall-clock time limit/,
|
||||
);
|
||||
});
|
||||
|
||||
test("script source and args are bounded", async () => {
|
||||
await assert.rejects(run("x".repeat(512 * 1024 + 1)), /source exceeds/);
|
||||
await assert.rejects(
|
||||
run("return null", { args: "x".repeat(256 * 1024 + 1) }),
|
||||
/args exceed/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { safeStringify, toSerializable } from "../_workflows/serialization.ts";
|
||||
|
||||
const MAX_SOURCE_BYTES = 512 * 1024;
|
||||
const MAX_ARGS_BYTES = 256 * 1024;
|
||||
const MAX_RESULT_BYTES = 1024 * 1024;
|
||||
const MAX_ASSIGNMENT_MESSAGE_BYTES = 512 * 1024;
|
||||
const MAX_ASSIGNMENT_REQUESTS = 32;
|
||||
const MAX_STAGE_UPDATES = 64;
|
||||
const DEFAULT_SCRIPT_RUNTIME_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface ScriptAssignmentResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
structured?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RunScriptSandboxOptions {
|
||||
source: string;
|
||||
args: unknown;
|
||||
cwd: string;
|
||||
signal: AbortSignal;
|
||||
runtimeTimeoutMs?: number;
|
||||
onAssign: (
|
||||
role: string,
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
requestId: number,
|
||||
) => Promise<ScriptAssignmentResult>;
|
||||
onStage: (title: string) => void;
|
||||
}
|
||||
|
||||
function byteLength(value: string) {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function terminateChild(child: ChildProcess) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGKILL");
|
||||
}, 1_000);
|
||||
force.unref?.();
|
||||
}
|
||||
|
||||
export function validateScriptIpcEnvelope(raw: unknown, token: string) {
|
||||
if (!isRecord(raw) || raw.token !== token || typeof raw.kind !== "string") {
|
||||
throw new Error("Script sandbox sent an invalid IPC message");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function parseScriptAssignmentPayload(payloadJson: unknown) {
|
||||
if (
|
||||
typeof payloadJson !== "string" ||
|
||||
byteLength(payloadJson) > MAX_ASSIGNMENT_MESSAGE_BYTES
|
||||
) {
|
||||
throw new Error("Script sandbox sent an oversized assignment request");
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(payloadJson);
|
||||
} catch {
|
||||
throw new Error("Script sandbox sent malformed assignment JSON");
|
||||
}
|
||||
if (
|
||||
!isRecord(payload) ||
|
||||
!Number.isSafeInteger(payload.id) ||
|
||||
typeof payload.id !== "number" ||
|
||||
payload.id < 1 ||
|
||||
typeof payload.role !== "string" ||
|
||||
payload.role.trim().length === 0 ||
|
||||
payload.role.length > 80 ||
|
||||
typeof payload.prompt !== "string" ||
|
||||
payload.prompt.length > 100_000
|
||||
) {
|
||||
throw new Error("Script sandbox sent an invalid assignment request");
|
||||
}
|
||||
return {
|
||||
id: payload.id,
|
||||
role: payload.role.trim(),
|
||||
prompt: payload.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
export class ScriptRequestBudget {
|
||||
private readonly ids = new Set<number>();
|
||||
private count = 0;
|
||||
|
||||
accept(id: number) {
|
||||
if (this.ids.has(id)) {
|
||||
throw new Error("Script sandbox sent a duplicate assignment request");
|
||||
}
|
||||
if (++this.count > MAX_ASSIGNMENT_REQUESTS) {
|
||||
throw new Error("Script sandbox exceeded its assignment request budget");
|
||||
}
|
||||
this.ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute orchestration code in a separate, permission-restricted Node process.
|
||||
* The child can only invoke the narrow agent/phase IPC protocol and is always
|
||||
* terminated on completion, cancellation, or protocol failure. The workflow
|
||||
* itself and its assignment requests have no wall-clock deadline. Active requests
|
||||
* are aborted only when the workflow is cancelled or the sandbox is cleaned up.
|
||||
*/
|
||||
export function runScriptSandbox(options: RunScriptSandboxOptions) {
|
||||
if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
|
||||
return Promise.reject(
|
||||
new Error("This Node runtime cannot enforce script child permissions"),
|
||||
);
|
||||
}
|
||||
if (byteLength(options.source) > MAX_SOURCE_BYTES) {
|
||||
return Promise.reject(
|
||||
new Error(`Script source exceeds the ${MAX_SOURCE_BYTES} byte limit`),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const exactArgsJson = JSON.stringify({
|
||||
defined: options.args !== undefined,
|
||||
value: options.args,
|
||||
});
|
||||
if (exactArgsJson && byteLength(exactArgsJson) > MAX_ARGS_BYTES) {
|
||||
return Promise.reject(new Error("Script args exceed the IPC limit"));
|
||||
}
|
||||
} catch {
|
||||
// The bounded serializer below handles cycles and hostile values.
|
||||
}
|
||||
|
||||
const argsJson = safeStringify(
|
||||
{ defined: options.args !== undefined, value: options.args },
|
||||
{ maxBytes: MAX_ARGS_BYTES + 1, maxDepth: 16, maxNodes: 10_000 },
|
||||
);
|
||||
if (byteLength(argsJson) > MAX_ARGS_BYTES) {
|
||||
return Promise.reject(new Error("Script args exceed the IPC limit"));
|
||||
}
|
||||
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const workerPath = fileURLToPath(
|
||||
new URL("./sandbox-child.cjs", import.meta.url),
|
||||
);
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--permission",
|
||||
`--allow-fs-read=${path.dirname(workerPath)}`,
|
||||
"--max-old-space-size=128",
|
||||
"--stack-size=2048",
|
||||
workerPath,
|
||||
],
|
||||
{
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
const token = randomBytes(24).toString("hex");
|
||||
const requestBudget = new ScriptRequestBudget();
|
||||
const activeAssignmentRequests = new Map<number, AbortController>();
|
||||
let stageCount = 0;
|
||||
let finished = false;
|
||||
const runtimeTimer = setTimeout(
|
||||
() => finish(new Error("Script exceeded its wall-clock time limit")),
|
||||
options.runtimeTimeoutMs ?? DEFAULT_SCRIPT_RUNTIME_MS,
|
||||
);
|
||||
runtimeTimer.unref?.();
|
||||
|
||||
const cleanup = () => {
|
||||
for (const abortController of activeAssignmentRequests.values()) {
|
||||
abortController.abort(new Error("Script stopped"));
|
||||
}
|
||||
activeAssignmentRequests.clear();
|
||||
clearTimeout(runtimeTimer);
|
||||
options.signal.removeEventListener("abort", onAbort);
|
||||
child.removeAllListeners("message");
|
||||
child.removeAllListeners("error");
|
||||
child.removeAllListeners("exit");
|
||||
terminateChild(child);
|
||||
};
|
||||
const finish = (error?: Error, value?: unknown) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve(value);
|
||||
};
|
||||
const onAbort = () => finish(new Error("Script was aborted"));
|
||||
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (options.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
|
||||
child.on("error", (error) => finish(error));
|
||||
child.on("exit", (code, exitSignal) => {
|
||||
if (!finished) {
|
||||
finish(
|
||||
new Error(
|
||||
`Script sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
child.on("message", (message: unknown) => {
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = validateScriptIpcEnvelope(message, token);
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
if (raw.kind === "stage") {
|
||||
if (++stageCount > MAX_STAGE_UPDATES) {
|
||||
finish(new Error("Script sandbox exceeded its stage update budget"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof raw.payloadJson !== "string" ||
|
||||
byteLength(raw.payloadJson) > 4096
|
||||
) {
|
||||
finish(new Error("Script sandbox sent an invalid stage update"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload: unknown = JSON.parse(raw.payloadJson);
|
||||
if (!isRecord(payload) || typeof payload.title !== "string") {
|
||||
throw new Error("invalid title");
|
||||
}
|
||||
options.onStage(payload.title.slice(0, 160));
|
||||
} catch {
|
||||
finish(new Error("Script sandbox sent an invalid stage update"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (raw.kind === "assign") {
|
||||
let payload: ReturnType<typeof parseScriptAssignmentPayload>;
|
||||
try {
|
||||
payload = parseScriptAssignmentPayload(raw.payloadJson);
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requestBudget.accept(payload.id);
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
const id = payload.id;
|
||||
const abortController = new AbortController();
|
||||
const sendResult = (result: ScriptAssignmentResult) => {
|
||||
if (!activeAssignmentRequests.delete(id)) return;
|
||||
if (finished || !child.connected) return;
|
||||
const normalized = toSerializable(result, {
|
||||
maxDepth: 16,
|
||||
maxNodes: 10_000,
|
||||
maxStringBytes: 128 * 1024,
|
||||
});
|
||||
let resultJson = JSON.stringify(normalized);
|
||||
if (byteLength(resultJson) > MAX_ASSIGNMENT_MESSAGE_BYTES) {
|
||||
resultJson = JSON.stringify({
|
||||
ok: false,
|
||||
output: "",
|
||||
error: "Assignment result exceeded the script IPC output limit",
|
||||
});
|
||||
}
|
||||
child.send({ token, kind: "assignmentResult", id, resultJson });
|
||||
};
|
||||
activeAssignmentRequests.set(id, abortController);
|
||||
void options
|
||||
.onAssign(
|
||||
payload.role.trim(),
|
||||
payload.prompt,
|
||||
abortController.signal,
|
||||
payload.id,
|
||||
)
|
||||
.then(sendResult)
|
||||
.catch((error) =>
|
||||
sendResult({ ok: false, output: "", error: errorText(error) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (raw.kind === "result") {
|
||||
if (
|
||||
typeof raw.resultJson !== "string" ||
|
||||
byteLength(raw.resultJson) > MAX_RESULT_BYTES
|
||||
) {
|
||||
finish(new Error("Script result exceeded the IPC limit"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const normalized = toSerializable(JSON.parse(raw.resultJson));
|
||||
finish(undefined, JSON.parse(JSON.stringify(normalized)));
|
||||
} catch (error) {
|
||||
finish(
|
||||
new Error(`Script returned invalid JSON: ${errorText(error)}`),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (raw.kind === "error" && typeof raw.error === "string") {
|
||||
finish(new Error(raw.error.slice(0, 16 * 1024)));
|
||||
return;
|
||||
}
|
||||
finish(new Error("Script sandbox sent an unknown IPC message"));
|
||||
});
|
||||
|
||||
child.send(
|
||||
{
|
||||
kind: "init",
|
||||
token,
|
||||
source: options.source,
|
||||
argsJson,
|
||||
},
|
||||
(error) => {
|
||||
if (error) finish(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
|
||||
import { readVerifiedReportArtifact, WorkflowStore } from "./store.ts";
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "workflow-store-"));
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function workflow(runId = "run-1"): WorkflowRun {
|
||||
return {
|
||||
runId,
|
||||
revision: 0,
|
||||
mode: "feature",
|
||||
status: "running",
|
||||
background: false,
|
||||
request: "Build the feature",
|
||||
featureName: "build-feature",
|
||||
parentSessionIds: ["session-1"],
|
||||
lease: {
|
||||
processId: 100,
|
||||
activeParentSessionId: "session-1",
|
||||
heartbeatAt: 1,
|
||||
},
|
||||
workspace: {
|
||||
state: "creating",
|
||||
sourceRoot: "/repository",
|
||||
root: `/repository/.worktrees/${runId}`,
|
||||
branch: "feature/build-feature",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
||||
pinnedProfiles: [],
|
||||
preflightEligibility: [],
|
||||
facts: {
|
||||
stage: "discover",
|
||||
taskKind: "feature",
|
||||
requestTerms: ["build", "feature"],
|
||||
candidatePaths: [],
|
||||
languages: [],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
},
|
||||
stages: [],
|
||||
gates: [],
|
||||
decisions: [],
|
||||
assignments: [],
|
||||
prototypeCandidates: [],
|
||||
prototypeCleanupCompleted: false,
|
||||
deliveries: [],
|
||||
commandReceipts: [],
|
||||
mutatorProfileNames: [],
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
test("plan revisions use immutable artifact paths while updating plan.md convenience", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
await store.createRun(workflow());
|
||||
const first = await store.savePlan("run-1", 1, "# First plan");
|
||||
const second = await store.savePlan("run-1", 2, "# Second plan");
|
||||
assert.notEqual(first.path, second.path);
|
||||
assert.equal(await readFile(first.path, "utf8"), "# First plan\n");
|
||||
assert.equal(await readFile(second.path, "utf8"), "# Second plan\n");
|
||||
});
|
||||
});
|
||||
|
||||
test("prototype evidence is retained after throwaway source cleanup", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
await store.createRun(workflow());
|
||||
const candidate = path.join(directory, "worktree", "prototypes", "candidate-1");
|
||||
await mkdir(candidate, { recursive: true });
|
||||
const reportPath = path.join(candidate, "mobile.json");
|
||||
const screenshotPath = path.join(candidate, "mobile.png");
|
||||
await writeFile(reportPath, '{"result":"passed"}', "utf8");
|
||||
await writeFile(screenshotPath, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||
const artifact = await store.savePrototypeArtifact(
|
||||
"run-1",
|
||||
"candidate-1",
|
||||
{ path: "prototypes/candidate-1" },
|
||||
path.join(directory, "worktree"),
|
||||
["prototypes/candidate-1/mobile.json", "prototypes/candidate-1/mobile.png"],
|
||||
);
|
||||
await rm(candidate, { recursive: true });
|
||||
assert.equal(artifact.retainedFiles.length, 2);
|
||||
assert.equal(await readFile(artifact.retainedFiles[0]!, "utf8"), '{"result":"passed"}');
|
||||
await stat(artifact.path);
|
||||
});
|
||||
});
|
||||
|
||||
test("terminal reports bound large persisted Decisions instead of blocking completion", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow();
|
||||
run.status = "completed";
|
||||
run.decisions.push({
|
||||
gateId: "gate-large",
|
||||
revision: 1,
|
||||
idempotencyKey: "large-decision",
|
||||
decidedAt: 1,
|
||||
value: {
|
||||
kind: "answer-questions",
|
||||
answers: { large: "x".repeat(900_000) },
|
||||
},
|
||||
});
|
||||
run.verificationEvidence = {
|
||||
focusedBehavioralTests: true,
|
||||
verifierSucceeded: true,
|
||||
commands: Array.from({ length: 1_024 }, (_, index) => ({
|
||||
id: `command-${index}`,
|
||||
executable: "node",
|
||||
args: ["--test", "x".repeat(2_000)],
|
||||
ownedPaths: [],
|
||||
kind: "test" as const,
|
||||
status: "succeeded" as const,
|
||||
})),
|
||||
};
|
||||
await store.createRun(run);
|
||||
const report = await store.saveReport(run);
|
||||
const contents = await readFile(report.path, "utf8");
|
||||
assert.match(contents, /^# workflow build-feature/m);
|
||||
assert.match(contents, /- Run ID: run-1/);
|
||||
assert.match(contents, /\[truncated\]/);
|
||||
assert.match(contents, /report truncated to 1 MiB/);
|
||||
assert.ok(Buffer.byteLength(contents, "utf8") <= 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
test("supported version-zero snapshots migrate missing lifecycle collections", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const legacy = workflow();
|
||||
const record = legacy as unknown as Record<string, unknown>;
|
||||
delete record.background;
|
||||
delete record.assignments;
|
||||
delete record.deliveries;
|
||||
delete record.prototypeCandidates;
|
||||
delete record.prototypeCleanupCompleted;
|
||||
delete record.commandReceipts;
|
||||
delete record.uiImpactEvidence;
|
||||
delete record.affectedUiSurfaces;
|
||||
delete record.reviewCycles;
|
||||
delete record.verificationCycles;
|
||||
delete (legacy.effectiveConfig as Partial<typeof legacy.effectiveConfig>)
|
||||
.standardAgents;
|
||||
const runDirectory = path.join(
|
||||
directory,
|
||||
"state",
|
||||
"runs",
|
||||
legacy.runId,
|
||||
"revisions",
|
||||
);
|
||||
await mkdir(runDirectory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(runDirectory, "0.json"),
|
||||
`${JSON.stringify({ schemaVersion: 0, run: legacy })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const loaded = await store.loadRun(legacy.runId);
|
||||
assert.equal(loaded.background, false);
|
||||
assert.deepEqual(loaded.assignments, []);
|
||||
assert.deepEqual(loaded.deliveries, []);
|
||||
assert.deepEqual(loaded.prototypeCandidates, []);
|
||||
assert.deepEqual(loaded.commandReceipts, []);
|
||||
assert.equal(loaded.prototypeCleanupCompleted, false);
|
||||
assert.deepEqual(
|
||||
loaded.effectiveConfig.standardAgents,
|
||||
DEFAULT_WORKFLOW_CONFIG.standardAgents,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("report reads reject tampering before dashboard display", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
const run = workflow();
|
||||
run.status = "completed";
|
||||
await store.createRun(run);
|
||||
const report = await store.saveReport(run);
|
||||
await writeFile(report.path, "tampered\n", "utf8");
|
||||
await assert.rejects(
|
||||
readVerifiedReportArtifact(report),
|
||||
/digest mismatch/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("delivery claims admit one process and recover only after expiry", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(path.join(directory, "state"));
|
||||
await store.createRun(workflow());
|
||||
const claims = await Promise.all([
|
||||
store.claimDelivery("run-1", "gate:key", "owner-a", 1_000),
|
||||
store.claimDelivery("run-1", "gate:key", "owner-b", 1_000),
|
||||
]);
|
||||
assert.equal(claims.filter(Boolean).length, 1);
|
||||
assert.equal(
|
||||
await store.claimDelivery("run-1", "gate:key", "owner-c", 30_999),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await store.claimDelivery("run-1", "gate:key", "owner-c", 31_000),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("current snapshots default newly introduced lifecycle collections", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
const legacy = workflow();
|
||||
const legacyRecord = legacy as unknown as Record<string, unknown>;
|
||||
delete legacyRecord.mutatorProfileNames;
|
||||
delete legacyRecord.prototypeCleanupCompleted;
|
||||
delete legacyRecord.uiImpactEvidence;
|
||||
delete legacyRecord.affectedUiSurfaces;
|
||||
delete legacyRecord.reviewCycles;
|
||||
delete legacyRecord.verificationCycles;
|
||||
await store.createRun(legacy);
|
||||
const loaded = await store.loadRun(legacy.runId);
|
||||
assert.deepEqual(loaded.mutatorProfileNames, []);
|
||||
assert.equal(loaded.prototypeCleanupCompleted, false);
|
||||
assert.deepEqual(loaded.uiImpactEvidence, []);
|
||||
assert.deepEqual(loaded.affectedUiSurfaces, []);
|
||||
assert.equal(loaded.reviewCycles, 0);
|
||||
assert.equal(loaded.verificationCycles, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test("atomic snapshots leave the complete previous value when replacement is interrupted", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let interrupt = false;
|
||||
const store = new WorkflowStore(directory, {
|
||||
beforeRename(target) {
|
||||
if (interrupt && target.endsWith(path.join("revisions", "1.json"))) {
|
||||
throw new Error("simulated interruption");
|
||||
}
|
||||
},
|
||||
});
|
||||
const initial = workflow();
|
||||
await store.createRun(initial);
|
||||
|
||||
interrupt = true;
|
||||
const changed = structuredClone(initial);
|
||||
changed.revision = 1;
|
||||
changed.status = "waiting";
|
||||
await assert.rejects(store.saveRun(changed), /simulated interruption/);
|
||||
assert.deepEqual(await store.loadRun(initial.runId), initial);
|
||||
|
||||
interrupt = false;
|
||||
await store.saveRun(changed);
|
||||
assert.deepEqual(await store.loadRun(initial.runId), changed);
|
||||
const info = await stat(
|
||||
path.join(directory, "runs", initial.runId, "workflow.json"),
|
||||
);
|
||||
assert.equal(info.mode & 0o777, 0o600);
|
||||
});
|
||||
});
|
||||
|
||||
test("revision-checked saves serialize concurrent writers", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
const initial = workflow();
|
||||
await store.createRun(initial);
|
||||
const left = structuredClone(initial);
|
||||
left.revision = 1;
|
||||
left.status = "waiting";
|
||||
const right = structuredClone(initial);
|
||||
right.revision = 1;
|
||||
right.status = "aborted";
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
store.saveRun(left, 0),
|
||||
store.saveRun(right, 0),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
assert.match(
|
||||
String(rejected && rejected.status === "rejected" && rejected.reason),
|
||||
/Stale workflow revision/,
|
||||
);
|
||||
assert.equal((await store.loadRun(initial.runId)).revision, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("a published revision survives interruption before the convenience snapshot", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let interrupt = false;
|
||||
const store = new WorkflowStore(directory, {
|
||||
beforeRename(target) {
|
||||
if (interrupt && target.endsWith("workflow.json")) {
|
||||
throw new Error("simulated pointer interruption");
|
||||
}
|
||||
},
|
||||
});
|
||||
const initial = workflow();
|
||||
await store.createRun(initial);
|
||||
const changed = structuredClone(initial);
|
||||
changed.revision = 1;
|
||||
changed.status = "waiting";
|
||||
interrupt = true;
|
||||
await assert.rejects(store.saveRun(changed, 0), /pointer interruption/);
|
||||
assert.deepEqual(await store.loadRun(initial.runId), changed);
|
||||
});
|
||||
});
|
||||
|
||||
test("run creation is atomically published and a pending start can retry", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let interrupt = true;
|
||||
const interruptedStore = new WorkflowStore(directory, {
|
||||
beforeRename(target) {
|
||||
if (interrupt && target.endsWith("request.md")) {
|
||||
throw new Error("simulated creation crash");
|
||||
}
|
||||
},
|
||||
});
|
||||
const initial = workflow();
|
||||
await interruptedStore.reserveStart(
|
||||
"launch-key",
|
||||
"fingerprint",
|
||||
initial.runId,
|
||||
);
|
||||
await assert.rejects(
|
||||
interruptedStore.createRun(initial),
|
||||
/simulated creation crash/,
|
||||
);
|
||||
assert.deepEqual(await interruptedStore.listRuns(), []);
|
||||
|
||||
interrupt = false;
|
||||
const reservation = await interruptedStore.reserveStart(
|
||||
"launch-key",
|
||||
"fingerprint",
|
||||
initial.runId,
|
||||
);
|
||||
assert.equal(reservation.created, false);
|
||||
await interruptedStore.createRun(initial);
|
||||
assert.deepEqual(
|
||||
(await interruptedStore.listRuns()).map((run) => run.runId),
|
||||
[initial.runId],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a concurrent publisher is reloaded instead of quarantined", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let markMiss: (() => void) | undefined;
|
||||
let releaseMiss: (() => void) | undefined;
|
||||
const missed = new Promise<void>((resolve) => {
|
||||
markMiss = resolve;
|
||||
});
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
releaseMiss = resolve;
|
||||
});
|
||||
const slow = new WorkflowStore(directory, {
|
||||
async afterRunLoadMiss() {
|
||||
markMiss?.();
|
||||
await wait;
|
||||
},
|
||||
});
|
||||
const fast = new WorkflowStore(directory);
|
||||
const slowRun = workflow("run-race");
|
||||
slowRun.request = "Slow publisher";
|
||||
const fastRun = workflow("run-race");
|
||||
fastRun.request = "Fast publisher";
|
||||
|
||||
const slowPublication = slow.createRun(slowRun);
|
||||
await missed;
|
||||
const fastPublication = await fast.createRun(fastRun);
|
||||
assert.equal(fastPublication.created, true);
|
||||
releaseMiss?.();
|
||||
const slowResult = await slowPublication;
|
||||
assert.equal(slowResult.created, false);
|
||||
assert.equal(slowResult.run.request, "Fast publisher");
|
||||
assert.equal((await fast.loadRun("run-race")).request, "Fast publisher");
|
||||
});
|
||||
});
|
||||
|
||||
test("incomplete legacy run directories are skipped and fail closed", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
await store.initialize();
|
||||
const initial = workflow();
|
||||
await mkdir(path.join(directory, "runs", initial.runId), { mode: 0o700 });
|
||||
assert.deepEqual(await store.listRuns(), []);
|
||||
await assert.rejects(
|
||||
store.createRun(initial),
|
||||
/incomplete published directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("start reservations are idempotent and recover an incomplete record", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let interrupt = true;
|
||||
const store = new WorkflowStore(directory, {
|
||||
beforeRename(target) {
|
||||
if (interrupt && target.endsWith("record.json")) {
|
||||
throw new Error("simulated reservation crash");
|
||||
}
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
store.reserveStart("launch-key", "fingerprint-a", "run-a"),
|
||||
/simulated reservation crash/,
|
||||
);
|
||||
interrupt = false;
|
||||
const recovered = await store.reserveStart(
|
||||
"launch-key",
|
||||
"fingerprint-a",
|
||||
"run-a",
|
||||
);
|
||||
assert.equal(recovered.created, true);
|
||||
const duplicate = await store.reserveStart(
|
||||
"launch-key",
|
||||
"fingerprint-a",
|
||||
"run-b",
|
||||
);
|
||||
assert.equal(duplicate.created, false);
|
||||
assert.equal(duplicate.record.runId, "run-a");
|
||||
await assert.rejects(
|
||||
store.reserveStart("launch-key", "fingerprint-b", "run-c"),
|
||||
/another start command/,
|
||||
);
|
||||
|
||||
const result = { runId: "run-a", revision: 3 };
|
||||
await store.completeStart("launch-key", result);
|
||||
assert.deepEqual(await store.getStart("launch-key"), {
|
||||
key: "launch-key",
|
||||
fingerprint: "fingerprint-a",
|
||||
runId: "run-a",
|
||||
status: "completed",
|
||||
result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("start completion keeps the highest durable run revision", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
await store.reserveStart("completion-key", "fingerprint", "run-a");
|
||||
await store.completeStart("completion-key", {
|
||||
runId: "run-a",
|
||||
revision: 5,
|
||||
status: "waiting",
|
||||
});
|
||||
await store.completeStart("completion-key", {
|
||||
runId: "run-a",
|
||||
revision: 2,
|
||||
status: "running",
|
||||
});
|
||||
const completed = await store.getStart("completion-key");
|
||||
assert.equal(completed?.status, "completed");
|
||||
assert.equal(
|
||||
(completed?.result as { revision?: number } | undefined)?.revision,
|
||||
5,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("concurrent start intents publish one authoritative run ID", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
const [left, right] = await Promise.all([
|
||||
store.reserveStart("claimed-key", "fingerprint", "run-left"),
|
||||
store.reserveStart("claimed-key", "fingerprint", "run-right"),
|
||||
]);
|
||||
assert.equal([left, right].filter((result) => result.created).length, 1);
|
||||
assert.equal(left.record.runId, right.record.runId);
|
||||
assert.ok(
|
||||
left.record.runId === "run-left" || left.record.runId === "run-right",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("run artifacts and plans are bounded, pinned, and discoverable", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const store = new WorkflowStore(directory);
|
||||
const first = workflow("run-a");
|
||||
const second = workflow("run-b");
|
||||
await store.createRun(first);
|
||||
await store.createRun(second);
|
||||
const plan = await store.savePlan(
|
||||
first.runId,
|
||||
4,
|
||||
"# Plan\n\nImplement it.",
|
||||
);
|
||||
assert.equal(plan.revision, 4);
|
||||
assert.match(plan.digest, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual(
|
||||
(await store.listRuns()).map((run) => run.runId),
|
||||
["run-a", "run-b"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,930 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
link,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type PlanArtifact,
|
||||
type WorkflowReportArtifact,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import { deriveRunLabel } from "./run-label.ts";
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
||||
const MAX_PROTOTYPE_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
async function readVerifiedDocument(
|
||||
artifact: { path: string; digest: string },
|
||||
label: string,
|
||||
maxBytes = MAX_DOCUMENT_BYTES,
|
||||
) {
|
||||
const details = await stat(artifact.path);
|
||||
if (!details.isFile() || details.size > maxBytes) {
|
||||
throw new Error(`${label} artifact exceeds the verified document limit`);
|
||||
}
|
||||
const contents = await readFile(artifact.path, "utf8");
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
if (digest !== artifact.digest) {
|
||||
throw new Error(`${label} artifact digest mismatch: ${artifact.path}`);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
export function readVerifiedPlanArtifact(artifact: PlanArtifact) {
|
||||
return readVerifiedDocument(artifact, "Plan");
|
||||
}
|
||||
|
||||
export function readVerifiedReportArtifact(artifact: WorkflowReportArtifact) {
|
||||
return readVerifiedDocument(artifact, "Report");
|
||||
}
|
||||
|
||||
export function readVerifiedTranscriptArtifact(artifact: {
|
||||
path: string;
|
||||
digest: string;
|
||||
}) {
|
||||
return readVerifiedDocument(artifact, "Transcript", MAX_JSON_BYTES);
|
||||
}
|
||||
|
||||
interface StoredSnapshot {
|
||||
schemaVersion: 0 | typeof SNAPSHOT_VERSION;
|
||||
run: WorkflowRun;
|
||||
}
|
||||
|
||||
export interface AssignmentLeaseRecord {
|
||||
token: string;
|
||||
processId: number;
|
||||
heartbeatAt: number;
|
||||
}
|
||||
|
||||
export interface StartRecord {
|
||||
key: string;
|
||||
fingerprint: string;
|
||||
runId: string;
|
||||
status: "pending" | "completed";
|
||||
result?: unknown;
|
||||
}
|
||||
|
||||
export interface WorkflowStoreOptions {
|
||||
beforeRename?: (target: string) => void | Promise<void>;
|
||||
afterRunLoadMiss?: (runId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function json(value: unknown, label: string) {
|
||||
const contents = `${JSON.stringify(value, null, 2)}\n`;
|
||||
if (Buffer.byteLength(contents) > MAX_JSON_BYTES) {
|
||||
throw new Error(`${label} exceeds ${MAX_JSON_BYTES} bytes`);
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(directory, "r");
|
||||
await handle.sync();
|
||||
} catch {
|
||||
// Some filesystems do not support directory fsync.
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowStore {
|
||||
readonly root: string;
|
||||
private readonly options: WorkflowStoreOptions;
|
||||
|
||||
constructor(root: string, options: WorkflowStoreOptions = {}) {
|
||||
this.root = root;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||
await mkdir(this.runsRoot(), { recursive: true, mode: 0o700 });
|
||||
await mkdir(this.startsRoot(), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
async createRun(run: WorkflowRun) {
|
||||
await this.initialize();
|
||||
const directory = this.runDirectory(run.runId);
|
||||
try {
|
||||
return { run: await this.loadRun(run.runId), created: false as const };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await this.options.afterRunLoadMiss?.(run.runId);
|
||||
|
||||
const existing = await stat(directory).catch(() => undefined);
|
||||
if (existing) {
|
||||
try {
|
||||
return { run: await this.loadRun(run.runId), created: false as const };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`Workflow Run ${run.runId} has an incomplete published directory; inspect and remove it before retrying`,
|
||||
);
|
||||
}
|
||||
const staging = path.join(
|
||||
this.runsRoot(),
|
||||
`.${run.runId}.${process.pid}.${randomUUID()}.creating`,
|
||||
);
|
||||
try {
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
await this.writeInitialRun(staging, run);
|
||||
await rename(staging, directory);
|
||||
await syncDirectory(this.runsRoot());
|
||||
return { run, created: true as const };
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === "EEXIST" ||
|
||||
(error as NodeJS.ErrnoException).code === "ENOTEMPTY"
|
||||
) {
|
||||
return {
|
||||
run: await this.loadRun(run.runId),
|
||||
created: false as const,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async saveRun(run: WorkflowRun, expectedRevision?: number) {
|
||||
const current = await this.loadRun(run.runId);
|
||||
const expected = expectedRevision ?? current.revision;
|
||||
if (current.revision !== expected || run.revision !== expected + 1) {
|
||||
throw new Error(
|
||||
`Stale workflow revision ${expected}; current revision is ${current.revision}`,
|
||||
);
|
||||
}
|
||||
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
|
||||
const contents = json(snapshot, `workflow ${run.runId}`);
|
||||
try {
|
||||
await this.atomicCreate(
|
||||
this.revisionPath(run.runId, run.revision),
|
||||
contents,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
const latest = await this.loadRun(run.runId);
|
||||
throw new Error(
|
||||
`Stale workflow revision ${expected}; current revision is ${latest.revision}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.atomicWrite(
|
||||
path.join(this.runDirectory(run.runId), "workflow.json"),
|
||||
contents,
|
||||
);
|
||||
}
|
||||
|
||||
async createAssignmentLease(
|
||||
runId: string,
|
||||
assignmentId: string,
|
||||
lease: AssignmentLeaseRecord,
|
||||
) {
|
||||
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
||||
if (existing && existing.token !== lease.token) {
|
||||
throw new Error(`Assignment ${assignmentId} already has an active lease`);
|
||||
}
|
||||
await this.atomicWrite(
|
||||
this.assignmentLeasePath(runId, assignmentId),
|
||||
json(lease, `assignment lease ${assignmentId}`),
|
||||
);
|
||||
return lease;
|
||||
}
|
||||
|
||||
async heartbeatAssignmentLease(
|
||||
runId: string,
|
||||
assignmentId: string,
|
||||
token: string,
|
||||
heartbeatAt: number,
|
||||
) {
|
||||
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
||||
if (!existing || existing.token !== token) return false;
|
||||
await this.atomicWrite(
|
||||
this.assignmentLeasePath(runId, assignmentId),
|
||||
json({ ...existing, heartbeatAt }, `assignment lease ${assignmentId}`),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadAssignmentLease(
|
||||
runId: string,
|
||||
assignmentId: string,
|
||||
): Promise<AssignmentLeaseRecord | undefined> {
|
||||
try {
|
||||
const parsed = await readJson(
|
||||
this.assignmentLeasePath(runId, assignmentId),
|
||||
);
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
typeof parsed.token !== "string" ||
|
||||
typeof parsed.processId !== "number" ||
|
||||
typeof parsed.heartbeatAt !== "number"
|
||||
) {
|
||||
throw new Error(`Assignment ${assignmentId} has an invalid lease`);
|
||||
}
|
||||
return {
|
||||
token: parsed.token,
|
||||
processId: parsed.processId,
|
||||
heartbeatAt: parsed.heartbeatAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseAssignmentLease(
|
||||
runId: string,
|
||||
assignmentId: string,
|
||||
token: string,
|
||||
) {
|
||||
const existing = await this.loadAssignmentLease(runId, assignmentId);
|
||||
if (!existing || existing.token !== token) return false;
|
||||
await rm(this.assignmentLeasePath(runId, assignmentId), { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadRun(runId: string): Promise<WorkflowRun> {
|
||||
const revisions = path.join(this.runDirectory(runId), "revisions");
|
||||
const entries = await readdir(revisions);
|
||||
const latest = entries
|
||||
.map((entry) => /^(\d+)\.json$/.exec(entry))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => Number(match[1]))
|
||||
.sort((left, right) => right - left)[0];
|
||||
if (latest === undefined) {
|
||||
throw new Error(`${revisions}: no workflow revisions`);
|
||||
}
|
||||
const file = this.revisionPath(runId, latest);
|
||||
const parsed = await readJson(file);
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
(parsed.schemaVersion !== 0 &&
|
||||
parsed.schemaVersion !== SNAPSHOT_VERSION) ||
|
||||
!isRecord(parsed.run) ||
|
||||
parsed.run.runId !== runId
|
||||
) {
|
||||
throw new Error(`${file}: unsupported or invalid workflow snapshot`);
|
||||
}
|
||||
// SAFETY: saveRun is the sole writer; the wrapper version and run ID were validated above.
|
||||
const run = parsed.run as unknown as WorkflowRun;
|
||||
run.background ??= false;
|
||||
run.assignments ??= [];
|
||||
run.deliveries ??= [];
|
||||
run.prototypeCandidates ??= [];
|
||||
run.commandReceipts ??= [];
|
||||
run.mutatorProfileNames ??= [];
|
||||
run.prototypeCleanupCompleted ??= false;
|
||||
run.uiImpactEvidence ??= [];
|
||||
run.affectedUiSurfaces ??= [];
|
||||
run.reviewCycles ??= 0;
|
||||
run.verificationCycles ??= 0;
|
||||
run.effectiveConfig.standardAgents ??= structuredClone(
|
||||
DEFAULT_WORKFLOW_CONFIG.standardAgents,
|
||||
);
|
||||
for (const assignment of run.assignments) {
|
||||
if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {};
|
||||
if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {};
|
||||
}
|
||||
for (const delivery of run.deliveries) {
|
||||
try {
|
||||
const receipt = await readJson(
|
||||
this.deliveryReceiptPath(run.runId, delivery.key),
|
||||
);
|
||||
if (isRecord(receipt) && typeof receipt.sentAt === "number") {
|
||||
delivery.state = "sent";
|
||||
delivery.sentAt = receipt.sentAt;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async listRuns() {
|
||||
await this.initialize();
|
||||
const entries = await readdir(this.runsRoot(), { withFileTypes: true });
|
||||
const runs: WorkflowRun[] = [];
|
||||
for (const entry of entries.sort((left, right) =>
|
||||
left.name === right.name ? 0 : left.name < right.name ? -1 : 1,
|
||||
)) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
const snapshot = path.join(this.runsRoot(), entry.name, "workflow.json");
|
||||
try {
|
||||
await stat(snapshot);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
||||
throw error;
|
||||
}
|
||||
runs.push(await this.loadRun(entry.name));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
async saveReport(run: WorkflowRun) {
|
||||
const lines = [
|
||||
`# workflow ${deriveRunLabel(run)}`,
|
||||
"",
|
||||
`- Run ID: ${run.runId}`,
|
||||
`- Status: ${run.status}`,
|
||||
`- Revision: ${run.revision}`,
|
||||
`- Mode: ${run.mode}`,
|
||||
`- Request: ${reportText(run.request, 8_000)}`,
|
||||
`- Worktree: ${run.workspace.root ?? "not required"}`,
|
||||
`- Branch: ${run.workspace.branch ?? "not required"}`,
|
||||
`- Plan: ${run.planArtifact?.path ?? "none"}`,
|
||||
"",
|
||||
"## Gates",
|
||||
...boundedReportEntries(
|
||||
run.gates,
|
||||
(gate) =>
|
||||
`- ${gate.kind}: ${gate.status} (${gate.id}, revision ${gate.revision})`,
|
||||
),
|
||||
"",
|
||||
"## Decisions",
|
||||
...boundedReportEntries(
|
||||
run.decisions,
|
||||
(decision) =>
|
||||
`- ${decision.gateId} revision ${decision.revision}: ${reportText(decision.value)}`,
|
||||
),
|
||||
"",
|
||||
"## Assignments",
|
||||
...boundedReportEntries(
|
||||
run.assignments,
|
||||
(assignment) =>
|
||||
`- ${assignment.role}/${assignment.profileName}: ${assignment.status} (${assignment.id})`,
|
||||
1_024,
|
||||
),
|
||||
"",
|
||||
"## Prototype Evidence",
|
||||
...boundedReportEntries(
|
||||
run.prototypeCandidates,
|
||||
(candidate) =>
|
||||
`- ${candidate.id}: ${reportText(candidate.route, 500)} | ${candidate.artifactPath}`,
|
||||
),
|
||||
"",
|
||||
"## Verification",
|
||||
...(run.verificationEvidence
|
||||
? boundedReportEntries(
|
||||
run.verificationEvidence.commands,
|
||||
(command) =>
|
||||
`- ${command.kind}: ${command.status} | ${reportText(`${command.executable} ${command.args.join(" ")}`, 2_000)}`,
|
||||
1_024,
|
||||
)
|
||||
: ["- none"]),
|
||||
"",
|
||||
"## Review",
|
||||
"```json",
|
||||
reportText(run.reviewVerdict ?? null, 64 * 1024),
|
||||
"```",
|
||||
...(run.error
|
||||
? [
|
||||
"",
|
||||
"## Error",
|
||||
`${run.error.code}: ${reportText(run.error.message, 8_000)}`,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const contents = boundedReportDocument(lines.join("\n"));
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const reportPath = path.join(
|
||||
this.runDirectory(run.runId),
|
||||
"artifacts",
|
||||
`report-${run.revision}-${digest}.md`,
|
||||
);
|
||||
await this.atomicWrite(reportPath, contents);
|
||||
return { path: reportPath, digest, revision: run.revision };
|
||||
}
|
||||
|
||||
async ensureResult(run: WorkflowRun) {
|
||||
try {
|
||||
await stat(path.join(this.runDirectory(run.runId), "result.json"));
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await this.publishResult(run);
|
||||
}
|
||||
|
||||
async publishResult(run: WorkflowRun) {
|
||||
await this.atomicWrite(
|
||||
path.join(this.runDirectory(run.runId), "result.json"),
|
||||
json(
|
||||
{
|
||||
runId: run.runId,
|
||||
revision: run.reportArtifact?.revision ?? run.revision,
|
||||
snapshotRevision: run.revision,
|
||||
status: run.status,
|
||||
reportPath: run.reportArtifact?.path,
|
||||
planPath: run.planArtifact?.path,
|
||||
worktreePath: run.workspace.root,
|
||||
reviewVerdict: run.reviewVerdict,
|
||||
error: run.error,
|
||||
},
|
||||
"workflow result",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async savePlan(runId: string, revision: number, contents: string) {
|
||||
const bounded = boundedDocument(contents, "plan");
|
||||
const digest = createHash("sha256").update(bounded).digest("hex");
|
||||
const directory = this.runDirectory(runId);
|
||||
const file = path.join(directory, "plan.md");
|
||||
const versionedFile = path.join(
|
||||
directory,
|
||||
"artifacts",
|
||||
`plan-${revision}-${digest}.md`,
|
||||
);
|
||||
await this.atomicWrite(versionedFile, bounded);
|
||||
await this.atomicWrite(file, bounded);
|
||||
const artifact: PlanArtifact = {
|
||||
path: versionedFile,
|
||||
revision,
|
||||
digest,
|
||||
};
|
||||
return artifact;
|
||||
}
|
||||
|
||||
async savePrototypeArtifact(
|
||||
runId: string,
|
||||
candidateId: string,
|
||||
evidence: unknown,
|
||||
sourceRoot: string,
|
||||
artifactFiles: string[],
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(candidateId)) {
|
||||
throw new Error("invalid prototype candidate ID");
|
||||
}
|
||||
const root = await realpath(sourceRoot);
|
||||
const artifactRoot = path.join(
|
||||
this.runDirectory(runId),
|
||||
"artifacts",
|
||||
"prototypes",
|
||||
candidateId,
|
||||
);
|
||||
let totalBytes = 0;
|
||||
const retainedFiles: string[] = [];
|
||||
for (const relativePath of [...new Set(artifactFiles)]) {
|
||||
if (!relativePath || path.isAbsolute(relativePath)) {
|
||||
throw new Error("prototype artifact path must be relative");
|
||||
}
|
||||
const target = await realpath(path.resolve(root, relativePath));
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error("prototype artifact path escapes source root");
|
||||
}
|
||||
const details = await lstat(target);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error("prototype artifacts must be regular files");
|
||||
}
|
||||
const contents = await readFile(target);
|
||||
totalBytes += contents.byteLength;
|
||||
if (totalBytes > MAX_PROTOTYPE_ARTIFACT_BYTES) {
|
||||
throw new Error("prototype artifacts exceed the 8 MiB retained limit");
|
||||
}
|
||||
const digest = createHash("sha256").update(contents).digest("hex");
|
||||
const destination = path.join(
|
||||
artifactRoot,
|
||||
`${digest}-${path.basename(relativePath)}`,
|
||||
);
|
||||
await this.atomicWrite(destination, contents);
|
||||
retainedFiles.push(destination);
|
||||
}
|
||||
const file = path.join(artifactRoot, "candidate.json");
|
||||
const contents = json(
|
||||
{ candidate: evidence, retainedFiles },
|
||||
`prototype candidate ${candidateId}`,
|
||||
);
|
||||
await this.atomicWrite(file, contents);
|
||||
return { path: file, retainedFiles };
|
||||
}
|
||||
|
||||
async saveAssignmentTranscript(
|
||||
runId: string,
|
||||
assignmentId: string,
|
||||
transcript: unknown[],
|
||||
) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
|
||||
throw new Error("invalid assignment ID");
|
||||
}
|
||||
const contents = json(transcript, `assignment transcript ${assignmentId}`);
|
||||
const file = path.join(
|
||||
this.runDirectory(runId),
|
||||
"artifacts",
|
||||
"transcripts",
|
||||
`${assignmentId}.json`,
|
||||
);
|
||||
await this.atomicWrite(file, contents);
|
||||
return {
|
||||
path: file,
|
||||
digest: createHash("sha256").update(contents).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
async claimDelivery(
|
||||
runId: string,
|
||||
key: string,
|
||||
token: string,
|
||||
claimedAt: number,
|
||||
) {
|
||||
const claimPath = this.deliveryClaimPath(runId, key);
|
||||
await mkdir(path.dirname(claimPath), { recursive: true, mode: 0o700 });
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(claimPath, "wx", 0o600);
|
||||
await handle.writeFile(
|
||||
`${JSON.stringify({ key, token, claimedAt })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
await syncDirectory(path.dirname(claimPath));
|
||||
return true;
|
||||
} catch (error) {
|
||||
await handle?.close();
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
const existing = await readJson(claimPath).catch(() => undefined);
|
||||
if (
|
||||
!isRecord(existing) ||
|
||||
typeof existing.claimedAt !== "number" ||
|
||||
claimedAt - existing.claimedAt < 30_000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const stalePath = `${claimPath}.${randomUUID()}.stale`;
|
||||
try {
|
||||
await rename(claimPath, stalePath);
|
||||
await rm(stalePath, { force: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async clearDeliveryClaim(runId: string, key: string) {
|
||||
await rm(this.deliveryClaimPath(runId, key), { force: true });
|
||||
}
|
||||
|
||||
async releaseDeliveryClaim(runId: string, key: string, token: string) {
|
||||
const claimPath = this.deliveryClaimPath(runId, key);
|
||||
const existing = await readJson(claimPath).catch(() => undefined);
|
||||
if (!isRecord(existing) || existing.token !== token) return;
|
||||
const releasedPath = `${claimPath}.${randomUUID()}.released`;
|
||||
try {
|
||||
await rename(claimPath, releasedPath);
|
||||
await rm(releasedPath, { force: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async markDeliverySent(runId: string, key: string, sentAt: number) {
|
||||
const run = await this.loadRun(runId);
|
||||
const delivery = run.deliveries.find((candidate) => candidate.key === key);
|
||||
if (!delivery || delivery.state === "sent") return run;
|
||||
await this.atomicWrite(
|
||||
this.deliveryReceiptPath(runId, key),
|
||||
`${JSON.stringify({ key, sentAt })}\n`,
|
||||
);
|
||||
delivery.state = "sent";
|
||||
delivery.sentAt = sentAt;
|
||||
return run;
|
||||
}
|
||||
|
||||
async getStart(key: string) {
|
||||
const directory = this.startDirectory(key);
|
||||
try {
|
||||
await stat(path.join(directory, "record.json"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
return this.readStartRecord(directory);
|
||||
}
|
||||
|
||||
async reserveStart(key: string, fingerprint: string, runId: string) {
|
||||
await this.initialize();
|
||||
const directory = this.startDirectory(key);
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
let created = false;
|
||||
let record: StartRecord;
|
||||
try {
|
||||
record = await this.readStartRecord(directory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
const candidate: StartRecord = {
|
||||
key,
|
||||
fingerprint,
|
||||
runId,
|
||||
status: "pending",
|
||||
};
|
||||
try {
|
||||
await this.atomicCreate(
|
||||
path.join(directory, "record.json"),
|
||||
json(candidate, "start record"),
|
||||
);
|
||||
record = candidate;
|
||||
created = true;
|
||||
} catch (createError) {
|
||||
if ((createError as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw createError;
|
||||
}
|
||||
record = await this.readStartRecord(directory);
|
||||
}
|
||||
}
|
||||
if (record.key !== key || record.fingerprint !== fingerprint) {
|
||||
throw new Error(
|
||||
"idempotency key was already used with another start command",
|
||||
);
|
||||
}
|
||||
return { created, claimed: true as const, record };
|
||||
}
|
||||
|
||||
async completeStart(key: string, result: unknown) {
|
||||
const directory = this.startDirectory(key);
|
||||
const record = await this.readStartRecord(directory);
|
||||
if (
|
||||
!isRecord(result) ||
|
||||
result.runId !== record.runId ||
|
||||
!Number.isInteger(result.revision) ||
|
||||
(result.revision as number) < 0
|
||||
) {
|
||||
throw new Error("start result does not match its Workflow Run revision");
|
||||
}
|
||||
const target = path.join(
|
||||
directory,
|
||||
"results",
|
||||
`${result.revision as number}.json`,
|
||||
);
|
||||
try {
|
||||
await this.atomicCreate(target, json(result, "start result"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
||||
}
|
||||
return this.readStartRecord(directory);
|
||||
}
|
||||
|
||||
async findRunByBranch(branch: string) {
|
||||
const runs = await this.listRuns();
|
||||
return runs.find((run) => run.workspace.branch === branch);
|
||||
}
|
||||
|
||||
private async writeInitialRun(directory: string, run: WorkflowRun) {
|
||||
await mkdir(path.join(directory, "profiles"), { mode: 0o700 });
|
||||
await mkdir(path.join(directory, "handoffs"), { mode: 0o700 });
|
||||
await mkdir(path.join(directory, "artifacts"), { mode: 0o700 });
|
||||
await mkdir(path.join(directory, "assignment-leases"), { mode: 0o700 });
|
||||
await mkdir(path.join(directory, "revisions"), { mode: 0o700 });
|
||||
const snapshot: StoredSnapshot = { schemaVersion: SNAPSHOT_VERSION, run };
|
||||
const contents = json(snapshot, `workflow ${run.runId}`);
|
||||
await this.atomicWrite(
|
||||
path.join(directory, "revisions", "0.json"),
|
||||
contents,
|
||||
);
|
||||
await this.atomicWrite(path.join(directory, "workflow.json"), contents);
|
||||
await this.atomicWrite(
|
||||
path.join(directory, "request.md"),
|
||||
boundedDocument(run.request, "request"),
|
||||
);
|
||||
await this.atomicWrite(
|
||||
path.join(directory, "config.json"),
|
||||
json(run.effectiveConfig, "effective configuration"),
|
||||
);
|
||||
for (const profile of run.pinnedProfiles) {
|
||||
await this.atomicWrite(
|
||||
path.join(directory, "profiles", `${profile.name}.json`),
|
||||
json(profile, `profile ${profile.name}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private runsRoot() {
|
||||
return path.join(this.root, "runs");
|
||||
}
|
||||
|
||||
private startsRoot() {
|
||||
return path.join(this.root, "starts");
|
||||
}
|
||||
|
||||
private runDirectory(runId: string) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(runId)) throw new Error("invalid run ID");
|
||||
return path.join(this.runsRoot(), runId);
|
||||
}
|
||||
|
||||
private deliveryClaimPath(runId: string, key: string) {
|
||||
const digest = createHash("sha256").update(key).digest("hex");
|
||||
return path.join(
|
||||
this.runDirectory(runId),
|
||||
"delivery-claims",
|
||||
`${digest}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private deliveryReceiptPath(runId: string, key: string) {
|
||||
const digest = createHash("sha256").update(key).digest("hex");
|
||||
return path.join(
|
||||
this.runDirectory(runId),
|
||||
"delivery-receipts",
|
||||
`${digest}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private revisionPath(runId: string, revision: number) {
|
||||
if (!Number.isInteger(revision) || revision < 0) {
|
||||
throw new Error("invalid workflow revision");
|
||||
}
|
||||
return path.join(this.runDirectory(runId), "revisions", `${revision}.json`);
|
||||
}
|
||||
|
||||
private assignmentLeasePath(runId: string, assignmentId: string) {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(assignmentId)) {
|
||||
throw new Error("invalid assignment ID");
|
||||
}
|
||||
return path.join(
|
||||
this.runDirectory(runId),
|
||||
"assignment-leases",
|
||||
`${assignmentId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
private startDigest(key: string) {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
private startDirectory(key: string) {
|
||||
return path.join(this.startsRoot(), this.startDigest(key));
|
||||
}
|
||||
|
||||
private async readStartRecord(directory: string) {
|
||||
const file = path.join(directory, "record.json");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await readJson(file);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
typeof parsed.key !== "string" ||
|
||||
typeof parsed.fingerprint !== "string" ||
|
||||
typeof parsed.runId !== "string" ||
|
||||
(parsed.status !== "pending" && parsed.status !== "completed")
|
||||
) {
|
||||
throw new Error(`${file}: invalid start record`);
|
||||
}
|
||||
// SAFETY: all persisted StartRecord identity and discriminator fields were validated above.
|
||||
const intent = parsed as unknown as StartRecord;
|
||||
const resultsDirectory = path.join(directory, "results");
|
||||
let resultEntries: string[] = [];
|
||||
try {
|
||||
resultEntries = await readdir(resultsDirectory);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
const latest = resultEntries
|
||||
.map((entry) => /^(\d+)\.json$/.exec(entry))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => Number(match[1]))
|
||||
.sort((left, right) => right - left)[0];
|
||||
if (latest === undefined) return { ...intent, status: "pending" as const };
|
||||
const result = await readJson(
|
||||
path.join(resultsDirectory, `${latest}.json`),
|
||||
);
|
||||
if (
|
||||
!isRecord(result) ||
|
||||
result.runId !== intent.runId ||
|
||||
result.revision !== latest
|
||||
) {
|
||||
throw new Error(`${resultsDirectory}: invalid start result`);
|
||||
}
|
||||
return { ...intent, status: "completed" as const, result };
|
||||
}
|
||||
|
||||
private async atomicCreate(target: string, contents: string) {
|
||||
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const temporary = path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(temporary, "wx", 0o600);
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await this.options.beforeRename?.(target);
|
||||
await link(temporary, target);
|
||||
await rm(temporary, { force: true });
|
||||
await syncDirectory(path.dirname(target));
|
||||
} catch (error) {
|
||||
await handle?.close();
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async atomicWrite(target: string, contents: string | Uint8Array) {
|
||||
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
const temporary = path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`,
|
||||
);
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(temporary, "wx", 0o600);
|
||||
if (typeof contents === "string") await handle.writeFile(contents, "utf8");
|
||||
else await handle.writeFile(contents);
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
await this.options.beforeRename?.(target);
|
||||
await rename(temporary, target);
|
||||
await syncDirectory(path.dirname(target));
|
||||
} catch (error) {
|
||||
await handle?.close();
|
||||
await rm(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reportText(value: unknown, maxCharacters = 2_000) {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const characters = [...text];
|
||||
return characters.length <= maxCharacters
|
||||
? text
|
||||
: `${characters.slice(0, maxCharacters).join("")}\n[truncated]`;
|
||||
}
|
||||
|
||||
function boundedReportEntries<T>(
|
||||
entries: T[],
|
||||
render: (entry: T) => string,
|
||||
limit = 128,
|
||||
) {
|
||||
const included = entries.slice(-limit).map(render);
|
||||
if (entries.length > limit) {
|
||||
included.unshift(`- [${entries.length - limit} earlier entries omitted]`);
|
||||
}
|
||||
return included;
|
||||
}
|
||||
|
||||
function boundedReportDocument(contents: string) {
|
||||
const normalized = contents.endsWith("\n") ? contents : `${contents}\n`;
|
||||
if (Buffer.byteLength(normalized, "utf8") <= MAX_DOCUMENT_BYTES) return normalized;
|
||||
const marker = "\n[report truncated to 1 MiB]\n";
|
||||
const limit = MAX_DOCUMENT_BYTES - Buffer.byteLength(marker, "utf8");
|
||||
let bytes = 0;
|
||||
const kept: string[] = [];
|
||||
for (const character of normalized) {
|
||||
const width = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + width > limit) break;
|
||||
kept.push(character);
|
||||
bytes += width;
|
||||
}
|
||||
return `${kept.join("")}${marker}`;
|
||||
}
|
||||
|
||||
function boundedDocument(contents: string, label: string) {
|
||||
if (Buffer.byteLength(contents) > MAX_DOCUMENT_BYTES) {
|
||||
throw new Error(`${label} exceeds ${MAX_DOCUMENT_BYTES} bytes`);
|
||||
}
|
||||
return contents.endsWith("\n") ? contents : `${contents}\n`;
|
||||
}
|
||||
|
||||
async function readJson(file: string) {
|
||||
const info = await stat(file);
|
||||
if (info.size > MAX_JSON_BYTES)
|
||||
throw new Error(`${file}: JSON file is too large`);
|
||||
const contents = await readFile(file, "utf8");
|
||||
try {
|
||||
return JSON.parse(contents) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`${file}: invalid JSON`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import {
|
||||
compactWorkflowDetails,
|
||||
registerWorkflowTool,
|
||||
workflowCommandFromTool,
|
||||
} from "./tool.ts";
|
||||
import { WorkspaceValidationError } from "./workspace.ts";
|
||||
|
||||
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
|
||||
return {
|
||||
runId: "run-1",
|
||||
featureName: "search-page",
|
||||
request: "Build search page",
|
||||
revision: 4,
|
||||
status: "waiting",
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/run-1",
|
||||
branch: "feature/search",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
stages: [],
|
||||
assignments: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
...overrides,
|
||||
background: overrides.background ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test("tool commands derive active tools for feature and analysis scripts", () => {
|
||||
assert.deepEqual(
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
workflow: {
|
||||
mode: "feature-script",
|
||||
source: "return null",
|
||||
args: '{"scope":"routes"}',
|
||||
insertionPoint: "before-design",
|
||||
},
|
||||
},
|
||||
["read", "grep", "bash"],
|
||||
),
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
script: {
|
||||
source: "return null",
|
||||
args: { scope: "routes" },
|
||||
insertionPoint: "before-design",
|
||||
activeToolNames: ["read", "grep", "bash"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const analysis = workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Audit persistence",
|
||||
workflow: {
|
||||
mode: "analysis-script",
|
||||
source: "return await assign('review', 'Inspect')",
|
||||
},
|
||||
idempotencyKey: "analysis-key",
|
||||
},
|
||||
["read", "ls"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-feature-key",
|
||||
allowDirtySource: true,
|
||||
},
|
||||
["read"],
|
||||
),
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-feature-key",
|
||||
allowDirtySource: true,
|
||||
},
|
||||
);
|
||||
assert.equal(analysis.action, "start-analysis");
|
||||
assert.deepEqual(
|
||||
analysis.action === "start-analysis" ? analysis.activeToolNames : [],
|
||||
["read", "ls"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tool commands reject open or incomplete action shapes", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
workflow: { mode: "feature-script", source: "return null" },
|
||||
},
|
||||
["read"],
|
||||
),
|
||||
/requires workflow.source and workflow.insertionPoint/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 2,
|
||||
idempotencyKey: "decision-key",
|
||||
decision: undefined,
|
||||
},
|
||||
["read"],
|
||||
),
|
||||
/decision is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test("compact tool details expose only valid continuation intents", () => {
|
||||
const details = compactWorkflowDetails(
|
||||
"inspect",
|
||||
workflowView({
|
||||
openGate: {
|
||||
id: "gate-1",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 4,
|
||||
openedAt: 1,
|
||||
},
|
||||
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: "gate-1",
|
||||
runRevision: 3,
|
||||
planRevision: 2,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(details.continuation, {
|
||||
command: "/workflow continue",
|
||||
runId: "run-1",
|
||||
});
|
||||
assert.deepEqual(details.openGate?.payload, {
|
||||
preparationId: "prep-1",
|
||||
planRevision: 2,
|
||||
contextRevision: 3,
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
outcome: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
assert.equal(
|
||||
compactWorkflowDetails(
|
||||
"inspect",
|
||||
workflowView({
|
||||
openGate: {
|
||||
id: "gate-1",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 4,
|
||||
openedAt: 1,
|
||||
},
|
||||
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: "gate-1",
|
||||
runRevision: 3,
|
||||
planRevision: 2,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
}),
|
||||
"session-2",
|
||||
).continuation,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
compactWorkflowDetails("inspect", workflowView()).continuation,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "start" && !command.allowDirtySource) {
|
||||
throw new WorkspaceValidationError(
|
||||
"dirty-source",
|
||||
"source changes are omitted",
|
||||
{
|
||||
sourceChanges: [{ status: "??", path: "local-notes.md" }],
|
||||
sourceChangesOmitted: 2,
|
||||
},
|
||||
);
|
||||
}
|
||||
return workflowView();
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
let confirmation = "";
|
||||
await definition.execute(
|
||||
"dirty-call",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key",
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
{
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async (factory: (
|
||||
tui: { requestRender(): void; terminal: { rows: number } },
|
||||
theme: { fg(color: string, text: string): string; bg(color: string, text: string): string },
|
||||
keybindings: object,
|
||||
done: (value: boolean) => void,
|
||||
) => { render(width: number): string[] }) => {
|
||||
const component = factory(
|
||||
{ requestRender() {}, terminal: { rows: 24 } },
|
||||
{
|
||||
fg: (_color, text) => text,
|
||||
bg: (_color, text) => text,
|
||||
},
|
||||
{},
|
||||
() => {},
|
||||
);
|
||||
confirmation = component.render(100).join("\n");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
assert.match(confirmation, /local-notes\.md/);
|
||||
assert.match(confirmation, /2 additional paths omitted/);
|
||||
assert.deepEqual(
|
||||
commands.map((command) =>
|
||||
command.action === "start" ? command.allowDirtySource : undefined,
|
||||
),
|
||||
[false, true],
|
||||
);
|
||||
|
||||
const explicitContext = {
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => {
|
||||
confirmation = "unexpected prompt";
|
||||
return false;
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
confirmation = "";
|
||||
await assert.rejects(
|
||||
definition.execute(
|
||||
"dirty-call-refused",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key-refused",
|
||||
allowDirtySource: false,
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
explicitContext,
|
||||
),
|
||||
/source changes are omitted/,
|
||||
);
|
||||
assert.equal(confirmation, "");
|
||||
|
||||
await definition.execute(
|
||||
"dirty-call-approved",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key-approved",
|
||||
allowDirtySource: true,
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
explicitContext,
|
||||
);
|
||||
assert.equal(confirmation, "");
|
||||
assert.deepEqual(
|
||||
commands.map((command) =>
|
||||
command.action === "start" ? command.allowDirtySource : undefined,
|
||||
),
|
||||
[false, true, false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("RPC dirty-source starts never open TUI overlays and require the flag", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "start" && !command.allowDirtySource) {
|
||||
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
|
||||
}
|
||||
return workflowView();
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
const ctx = {
|
||||
mode: "rpc",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => {
|
||||
throw new Error("RPC must not open a custom overlay");
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
await assert.rejects(
|
||||
definition.execute(
|
||||
"rpc-dirty-rejected",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key-rejected",
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
ctx,
|
||||
),
|
||||
/explicit approval required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
definition.execute(
|
||||
"rpc-dirty-refused",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key-refused",
|
||||
allowDirtySource: false,
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
ctx,
|
||||
),
|
||||
/explicit approval required/,
|
||||
);
|
||||
await definition.execute(
|
||||
"rpc-dirty-approved",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "dirty-key-approved",
|
||||
allowDirtySource: true,
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands.map((command) =>
|
||||
command.action === "start" ? command.allowDirtySource : undefined,
|
||||
),
|
||||
[undefined, false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("start tool cancellation waits for engine settlement before rejecting", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
let markStarted = () => {};
|
||||
let releaseStart = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseStart = resolve;
|
||||
});
|
||||
let cancelled = false;
|
||||
let cancellationReason = new Error("cancelled before mutation");
|
||||
let laterMutation = false;
|
||||
let engineOperation: Promise<WorkflowView> | undefined;
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async cancelStart(
|
||||
_idempotencyKey: string,
|
||||
_abortIdempotencyKey: string,
|
||||
reason: Error,
|
||||
) {
|
||||
cancelled = true;
|
||||
cancellationReason = reason;
|
||||
await engineOperation?.catch(() => {});
|
||||
},
|
||||
execute() {
|
||||
engineOperation = (async () => {
|
||||
markStarted();
|
||||
await blocked;
|
||||
if (cancelled) throw cancellationReason;
|
||||
laterMutation = true;
|
||||
return workflowView();
|
||||
})();
|
||||
return engineOperation;
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
const controller = new AbortController();
|
||||
const operation = definition.execute(
|
||||
"cancel-slow-start",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "cancel-slow-start-key",
|
||||
},
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
mode: "rpc",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
await started;
|
||||
let toolSettled = false;
|
||||
void operation.then(
|
||||
() => {
|
||||
toolSettled = true;
|
||||
},
|
||||
() => {
|
||||
toolSettled = true;
|
||||
},
|
||||
);
|
||||
controller.abort(new Error("cancelled by tool caller"));
|
||||
await Promise.resolve();
|
||||
assert.equal(toolSettled, false);
|
||||
releaseStart();
|
||||
|
||||
await assert.rejects(operation, /cancelled by tool caller/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(laterMutation, false);
|
||||
});
|
||||
|
||||
test("cancellation during dirty-source confirmation cannot start a retry", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
let worktreeCreations = 0;
|
||||
let showConfirmation = () => {};
|
||||
const confirmationShown = new Promise<void>((resolve) => {
|
||||
showConfirmation = resolve;
|
||||
});
|
||||
let finishConfirmation = (_value: boolean) => {};
|
||||
const confirmation = new Promise<boolean>((resolve) => {
|
||||
finishConfirmation = resolve;
|
||||
});
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {
|
||||
async getStart() {
|
||||
return { runId: "not-created" };
|
||||
},
|
||||
async loadRun() {
|
||||
throw new Error("not created");
|
||||
},
|
||||
} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async cancelStart() {},
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "start" && command.allowDirtySource === true) {
|
||||
worktreeCreations++;
|
||||
return workflowView();
|
||||
}
|
||||
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
const controller = new AbortController();
|
||||
const operation = definition.execute(
|
||||
"cancel-dirty-confirmation",
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "cancel-dirty-key",
|
||||
},
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
mode: "tui",
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
ui: {
|
||||
custom: async () => {
|
||||
showConfirmation();
|
||||
return confirmation;
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
await confirmationShown;
|
||||
controller.abort(new Error("cancelled while confirming"));
|
||||
await assert.rejects(operation, /cancelled while confirming/);
|
||||
finishConfirmation(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(
|
||||
commands.map((command) =>
|
||||
command.action === "start" ? command.allowDirtySource : undefined,
|
||||
),
|
||||
[false],
|
||||
);
|
||||
assert.equal(worktreeCreations, 0);
|
||||
});
|
||||
|
||||
test("tool cancellation aborts its active Workflow Run before rejecting", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
let finishDecision = (_view: WorkflowView) => {};
|
||||
const decision = new Promise<WorkflowView>((resolve) => {
|
||||
finishDecision = resolve;
|
||||
});
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {
|
||||
async loadRun() {
|
||||
return { runId: "run-1", revision: 4, status: "running" };
|
||||
},
|
||||
} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "abort") {
|
||||
const aborted = workflowView({ status: "aborted", revision: 5 });
|
||||
finishDecision(aborted);
|
||||
return aborted;
|
||||
}
|
||||
return decision;
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
const controller = new AbortController();
|
||||
const operation = definition.execute(
|
||||
"cancel-call",
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 4,
|
||||
idempotencyKey: "decision-key",
|
||||
decision: { kind: "recover", resolution: "inspect" },
|
||||
},
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
controller.abort(new Error("cancelled by test"));
|
||||
await assert.rejects(operation, /cancelled by test/);
|
||||
assert.equal(commands.some((command) => command.action === "abort"), true);
|
||||
});
|
||||
|
||||
test("inspect errors retain the supplied run ID when no label can be derived", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute() {
|
||||
throw new Error("not found");
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
await assert.rejects(
|
||||
definition.execute(
|
||||
"inspect-call",
|
||||
{ action: "inspect", runId: "missing-run-id" },
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
{
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
),
|
||||
/Workflow Run missing-run-id could not be loaded/,
|
||||
);
|
||||
});
|
||||
|
||||
test("registered tool executes through the context engine and injects host tools", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools() {
|
||||
return ["read", "smart_compact"];
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return workflowView();
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.equal(definition?.name, "workflow");
|
||||
assert.ok(definition);
|
||||
const result = await definition.execute(
|
||||
"call-1",
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 4,
|
||||
idempotencyKey: "context-report",
|
||||
decision: {
|
||||
kind: "report-context-preparation",
|
||||
preparationId: "prep-1",
|
||||
planRevision: 2,
|
||||
method: "smart_compact",
|
||||
outcome: "succeeded",
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
{
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined,
|
||||
["read", "smart_compact"],
|
||||
);
|
||||
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Workflow search-page/);
|
||||
assert.equal((result.details as { runId: string }).runId, "run-1");
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { Type, type Static } from "typebox";
|
||||
import { isValidContextContinuation } from "./continuation.ts";
|
||||
import type { GateDecision } from "./domain.ts";
|
||||
import {
|
||||
executeHostDecision,
|
||||
type ContextTransitionRuntime,
|
||||
} from "./context-transition.ts";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import { deriveRunLabel } from "./run-label.ts";
|
||||
import { workflowConfirm } from "./tui.ts";
|
||||
import { WorkspaceValidationError } from "./workspace.ts";
|
||||
|
||||
const ACTIONS = ["start", "inspect", "decide", "abort"] as const;
|
||||
const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
|
||||
const INSERTION_POINTS = [
|
||||
"before-design",
|
||||
"after-design",
|
||||
"before-review",
|
||||
] as const;
|
||||
const DECISION_KINDS = [
|
||||
"answer-questions",
|
||||
"approve-plan",
|
||||
"revise-plan",
|
||||
"accept-prototype",
|
||||
"refine-prototypes",
|
||||
"select-implementation-context",
|
||||
"report-context-preparation",
|
||||
"recover",
|
||||
] as const;
|
||||
|
||||
const WorkflowDefinition = Type.Object(
|
||||
{
|
||||
mode: StringEnum(WORKFLOW_MODES),
|
||||
insertionPoint: Type.Optional(StringEnum(INSERTION_POINTS)),
|
||||
source: Type.Optional(Type.String()),
|
||||
args: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const GateDecisionInput = Type.Object(
|
||||
{
|
||||
kind: StringEnum(DECISION_KINDS),
|
||||
answers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
approved: Type.Optional(Type.Boolean()),
|
||||
acceptedUiImpact: Type.Optional(
|
||||
StringEnum(["none", "minor", "non-trivial"] as const),
|
||||
),
|
||||
comment: Type.Optional(Type.String()),
|
||||
feedback: Type.Optional(Type.String()),
|
||||
candidateId: Type.Optional(Type.String()),
|
||||
candidateRevision: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
browserReviewed: Type.Optional(Type.Boolean()),
|
||||
strategy: Type.Optional(
|
||||
StringEnum(["fresh-session", "smart-compact", "current-session"] as const),
|
||||
),
|
||||
preparationId: Type.Optional(Type.String()),
|
||||
planRevision: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
method: Type.Optional(StringEnum(["smart_compact"] as const)),
|
||||
outcome: Type.Optional(
|
||||
StringEnum(["unavailable", "succeeded", "no-op", "failed"] as const),
|
||||
),
|
||||
error: Type.Optional(Type.String()),
|
||||
resolution: Type.Optional(
|
||||
StringEnum(["retry", "inspect", "abort"] as const),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const WorkflowParameters = Type.Object(
|
||||
{
|
||||
action: StringEnum(ACTIONS),
|
||||
request: Type.Optional(Type.String()),
|
||||
featureName: Type.Optional(Type.String()),
|
||||
workflow: Type.Optional(WorkflowDefinition),
|
||||
concurrency: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
background: Type.Optional(Type.Boolean()),
|
||||
allowDirtySource: Type.Optional(Type.Boolean()),
|
||||
runId: Type.Optional(Type.String()),
|
||||
gateId: Type.Optional(Type.String()),
|
||||
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
decision: Type.Optional(GateDecisionInput),
|
||||
idempotencyKey: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export type WorkflowInput = Static<typeof WorkflowParameters>;
|
||||
|
||||
function requiredString(value: unknown, label: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function scriptArgs(value: string | undefined) {
|
||||
if (value === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function workflowCommandFromTool(
|
||||
input: WorkflowInput,
|
||||
activeToolNames: string[],
|
||||
): WorkflowCommand {
|
||||
if (input.action === "start") {
|
||||
const request = requiredString(input.request, "request");
|
||||
const idempotencyKey = requiredString(
|
||||
input.idempotencyKey,
|
||||
"idempotencyKey",
|
||||
);
|
||||
const definition = input.workflow ?? { mode: "feature" as const };
|
||||
if (definition.mode === "analysis-script") {
|
||||
return {
|
||||
action: "start-analysis",
|
||||
request,
|
||||
source: requiredString(definition.source, "workflow.source"),
|
||||
...(definition.args !== undefined
|
||||
? { args: scriptArgs(definition.args) }
|
||||
: {}),
|
||||
activeToolNames: [...activeToolNames],
|
||||
idempotencyKey,
|
||||
};
|
||||
}
|
||||
const featureName = requiredString(input.featureName, "featureName");
|
||||
if (
|
||||
definition.mode === "feature-script" &&
|
||||
(!definition.insertionPoint || !definition.source)
|
||||
) {
|
||||
throw new Error(
|
||||
"feature-script requires workflow.source and workflow.insertionPoint",
|
||||
);
|
||||
}
|
||||
return {
|
||||
action: "start",
|
||||
request,
|
||||
featureName,
|
||||
idempotencyKey,
|
||||
...(input.concurrency !== undefined
|
||||
? { concurrency: input.concurrency }
|
||||
: {}),
|
||||
...(input.background !== undefined
|
||||
? { background: input.background }
|
||||
: {}),
|
||||
...(input.allowDirtySource !== undefined
|
||||
? { allowDirtySource: input.allowDirtySource }
|
||||
: {}),
|
||||
...(definition.mode === "feature-script"
|
||||
? {
|
||||
script: {
|
||||
source: requiredString(definition.source, "workflow.source"),
|
||||
...(definition.args !== undefined
|
||||
? { args: scriptArgs(definition.args) }
|
||||
: {}),
|
||||
insertionPoint: definition.insertionPoint!,
|
||||
activeToolNames: [...activeToolNames],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const runId = requiredString(input.runId, "runId");
|
||||
if (input.action === "inspect") return { action: "inspect", runId };
|
||||
const expectedRevision = input.expectedRevision;
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision! < 0) {
|
||||
throw new Error("expectedRevision is required");
|
||||
}
|
||||
const idempotencyKey = requiredString(
|
||||
input.idempotencyKey,
|
||||
"idempotencyKey",
|
||||
);
|
||||
if (input.action === "abort") {
|
||||
return {
|
||||
action: "abort",
|
||||
runId,
|
||||
expectedRevision: expectedRevision!,
|
||||
idempotencyKey,
|
||||
};
|
||||
}
|
||||
if (!input.decision) throw new Error("decision is required");
|
||||
return {
|
||||
action: "decide",
|
||||
runId,
|
||||
gateId: requiredString(input.gateId, "gateId"),
|
||||
expectedRevision: expectedRevision!,
|
||||
decision: input.decision as GateDecision,
|
||||
idempotencyKey,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowToolDetails {
|
||||
action: WorkflowInput["action"];
|
||||
runId: string;
|
||||
revision: number;
|
||||
status: WorkflowView["status"];
|
||||
openGate?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
revision: number;
|
||||
payload?: unknown;
|
||||
};
|
||||
continuation?: { command: string; runId: string };
|
||||
planPath?: string;
|
||||
worktreePath?: string;
|
||||
assignmentCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
function boundedValue(value: unknown, budget = { characters: 24_000 }, depth = 0): unknown {
|
||||
if (budget.characters <= 0) return "[truncated]";
|
||||
if (typeof value === "string") {
|
||||
const kept = [...value].slice(0, budget.characters).join("");
|
||||
budget.characters -= [...kept].length;
|
||||
return kept.length < value.length ? `${kept}[truncated]` : kept;
|
||||
}
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (depth >= 6) return "[depth truncated]";
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, 100)
|
||||
.map((entry) => boundedValue(entry, budget, depth + 1));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.slice(0, 100)
|
||||
.map(([key, entry]) => [
|
||||
key.slice(0, 200),
|
||||
boundedValue(entry, budget, depth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, max = 2_000) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return [...value].slice(0, max).join("");
|
||||
}
|
||||
|
||||
function compactGatePayload(view: WorkflowView) {
|
||||
const gate = view.openGate;
|
||||
const payload = gate?.payload;
|
||||
if (!gate) return undefined;
|
||||
if (gate.kind === "implementation-context") {
|
||||
const context = view.implementationContext;
|
||||
return context
|
||||
? {
|
||||
preparationId: context.preparationId,
|
||||
planRevision: context.planRevision,
|
||||
contextRevision: context.runRevision,
|
||||
strategy: context.strategy,
|
||||
status: context.status,
|
||||
method: context.method,
|
||||
outcome: context.outcome,
|
||||
error: boundedString(context.error),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (gate.kind === "questions") {
|
||||
const questions = Array.isArray(record.questions) ? record.questions : [];
|
||||
return {
|
||||
questions: questions.slice(0, 20).flatMap((question) => {
|
||||
if (!question || typeof question !== "object") return [];
|
||||
const item = question as Record<string, unknown>;
|
||||
const id = boundedString(item.id, 160);
|
||||
const prompt = boundedString(item.prompt);
|
||||
return id && prompt ? [{ id, prompt }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (gate.kind === "plan-approval") {
|
||||
return {
|
||||
planArtifact: boundedValue(record.planArtifact),
|
||||
uiImpact: record.uiImpact,
|
||||
uiImpactEvidence: Array.isArray(record.uiImpactEvidence)
|
||||
? record.uiImpactEvidence.slice(0, 20).map((entry) => boundedString(entry, 500))
|
||||
: [],
|
||||
affectedUiSurfaces: Array.isArray(record.affectedUiSurfaces)
|
||||
? record.affectedUiSurfaces.slice(0, 20).map((entry) => boundedString(entry, 500))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
if (gate.kind === "prototype-acceptance") {
|
||||
const candidates = Array.isArray(record.candidates) ? record.candidates : [];
|
||||
return {
|
||||
candidates: candidates.slice(-3).map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return candidate;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
return {
|
||||
id: item.id,
|
||||
revision: item.revision,
|
||||
route: boundedString(item.route),
|
||||
structuralRationale: boundedString(item.structuralRationale),
|
||||
launchInstructions: boundedString(item.launchInstructions),
|
||||
artifactPath: boundedString(item.artifactPath),
|
||||
retainedArtifacts: Array.isArray(item.retainedArtifacts)
|
||||
? item.retainedArtifacts
|
||||
.slice(0, 10)
|
||||
.map((entry) => boundedString(entry, 1_000))
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return boundedValue(record);
|
||||
}
|
||||
|
||||
export function compactWorkflowDetails(
|
||||
action: WorkflowInput["action"],
|
||||
view: WorkflowView,
|
||||
sessionId?: string,
|
||||
): WorkflowToolDetails {
|
||||
const assignmentCounts: Record<string, number> = {};
|
||||
const gatePayload = compactGatePayload(view);
|
||||
for (const assignment of view.assignments) {
|
||||
assignmentCounts[assignment.status] =
|
||||
(assignmentCounts[assignment.status] ?? 0) + 1;
|
||||
}
|
||||
const continuation = isValidContextContinuation(view, sessionId)
|
||||
? { command: "/workflow continue", runId: view.runId }
|
||||
: undefined;
|
||||
return {
|
||||
action,
|
||||
runId: view.runId,
|
||||
revision: view.revision,
|
||||
status: view.status,
|
||||
...(view.openGate
|
||||
? {
|
||||
openGate: {
|
||||
id: view.openGate.id,
|
||||
kind: view.openGate.kind,
|
||||
revision: view.openGate.revision,
|
||||
...(gatePayload !== undefined
|
||||
? { payload: gatePayload }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(continuation ? { continuation } : {}),
|
||||
...(view.planArtifact ? { planPath: view.planArtifact.path } : {}),
|
||||
...(view.workspace.root ? { worktreePath: view.workspace.root } : {}),
|
||||
assignmentCounts,
|
||||
};
|
||||
}
|
||||
|
||||
function boundToolOutput(contents: string) {
|
||||
const maxBytes = 48 * 1024;
|
||||
if (Buffer.byteLength(contents, "utf8") <= maxBytes) return contents;
|
||||
const marker = "\n[tool output truncated]";
|
||||
const limit = maxBytes - Buffer.byteLength(marker, "utf8");
|
||||
let bytes = 0;
|
||||
const characters: string[] = [];
|
||||
for (const character of contents) {
|
||||
const width = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + width > limit) break;
|
||||
characters.push(character);
|
||||
bytes += width;
|
||||
}
|
||||
return `${characters.join("")}${marker}`;
|
||||
}
|
||||
|
||||
function resultText(details: WorkflowToolDetails, label: string) {
|
||||
const lines = [
|
||||
`Workflow ${label}: ${details.status} at revision ${details.revision}.`,
|
||||
];
|
||||
if (details.openGate) {
|
||||
lines.push(
|
||||
`Open Gate: ${details.openGate.kind} (${details.openGate.id}). Obtain the user's explicit Decision before deciding it.`,
|
||||
);
|
||||
if (details.openGate.payload !== undefined) {
|
||||
lines.push(`Gate context: ${JSON.stringify(details.openGate.payload)}`);
|
||||
}
|
||||
}
|
||||
if (details.continuation) {
|
||||
lines.push(`${details.continuation.command} ${details.continuation.runId}`);
|
||||
}
|
||||
if (details.worktreePath) lines.push(`Worktree: ${details.worktreePath}`);
|
||||
if (details.planPath) lines.push(`Plan: ${details.planPath}`);
|
||||
return boundToolOutput(lines.join("\n"));
|
||||
}
|
||||
|
||||
async function cancelToolWorkflow(
|
||||
runtime: ContextTransitionRuntime,
|
||||
engine: Awaited<ReturnType<ContextTransitionRuntime["engineForContext"]>>,
|
||||
input: WorkflowInput,
|
||||
toolCallId: string,
|
||||
) {
|
||||
const runId = input.runId;
|
||||
if (!runId) return;
|
||||
const current = await runtime.store.loadRun(runId).catch(() => undefined);
|
||||
if (
|
||||
!current ||
|
||||
current.status === "completed" ||
|
||||
current.status === "failed" ||
|
||||
current.status === "aborted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await engine.execute({
|
||||
action: "abort",
|
||||
runId,
|
||||
expectedRevision: current.revision,
|
||||
idempotencyKey: `tool-cancel-${toolCallId}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function dirtySourceWarning(error: WorkspaceValidationError) {
|
||||
const visibleChanges = error.sourceChanges.slice(0, 6);
|
||||
const changes = visibleChanges.map(
|
||||
(change) => `${change.status} ${change.path}`,
|
||||
);
|
||||
const omitted =
|
||||
error.sourceChanges.length - visibleChanges.length +
|
||||
error.sourceChangesOmitted;
|
||||
if (omitted > 0) {
|
||||
changes.push(`[${omitted} additional paths omitted]`);
|
||||
}
|
||||
return [
|
||||
"The workflow worktree will start at the repository's current HEAD.",
|
||||
"These staged, unstaged, or untracked source-only changes will be omitted:",
|
||||
...changes,
|
||||
"Continue only if the accepted plan should exclude those changes.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function registerWorkflowTool(
|
||||
pi: ExtensionAPI,
|
||||
runtime: ContextTransitionRuntime,
|
||||
) {
|
||||
pi.registerTool({
|
||||
name: "workflow",
|
||||
label: "Pi Workflow",
|
||||
description:
|
||||
"Start, inspect, decide, or abort a persisted gated feature or read-only analysis Workflow Run. Noninteractive starts from a dirty source require allowDirtySource=true. Mutating actions require idempotency keys and expected revisions. Gate Decisions must come from the user; never infer approval.",
|
||||
promptSnippet:
|
||||
"Plan and build features through persisted Gates, worktrees, verification, and independent review",
|
||||
promptGuidelines: [
|
||||
"Use workflow when the user asks to plan or build a non-trivial feature through the managed workflow.",
|
||||
"Never use workflow decide to infer a Gate Decision; obtain the user's explicit answer, plan approval, prototype acceptance, context choice, or recovery choice first.",
|
||||
"Use workflow inspect before every state-changing call and pass its current revision and Gate ID.",
|
||||
],
|
||||
parameters: WorkflowParameters,
|
||||
async execute(toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
|
||||
const engine = await runtime.engineForContext(ctx);
|
||||
const command = workflowCommandFromTool(params, pi.getActiveTools());
|
||||
const executionSignal = signal ?? new AbortController().signal;
|
||||
const cancellationError = () =>
|
||||
executionSignal.reason instanceof Error
|
||||
? executionSignal.reason
|
||||
: new Error("workflow tool call was cancelled");
|
||||
const operation = (async () => {
|
||||
if (executionSignal.aborted) throw cancellationError();
|
||||
if (command.action === "decide") {
|
||||
return executeHostDecision(pi, engine, command);
|
||||
}
|
||||
const initial =
|
||||
command.action === "start" &&
|
||||
ctx.mode === "tui" &&
|
||||
command.allowDirtySource === undefined
|
||||
? { ...command, allowDirtySource: false }
|
||||
: command;
|
||||
try {
|
||||
return await engine.execute(initial);
|
||||
} catch (error) {
|
||||
if (
|
||||
command.action !== "start" ||
|
||||
ctx.mode !== "tui" ||
|
||||
command.allowDirtySource !== undefined ||
|
||||
!(error instanceof WorkspaceValidationError) ||
|
||||
error.code !== "dirty-source"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const confirmed = await workflowConfirm(
|
||||
ctx,
|
||||
"Dirty source checkout",
|
||||
dirtySourceWarning(error),
|
||||
);
|
||||
if (executionSignal.aborted) throw cancellationError();
|
||||
if (!confirmed) {
|
||||
throw new Error("Workflow start cancelled because source changes would be omitted");
|
||||
}
|
||||
return engine.execute({ ...command, allowDirtySource: true });
|
||||
}
|
||||
})();
|
||||
let rejectCancellation = (_error: unknown) => {};
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
rejectCancellation = reject;
|
||||
});
|
||||
let cancellationWork: Promise<void> | undefined;
|
||||
const onAbort = () => {
|
||||
cancellationWork =
|
||||
command.action === "start" || command.action === "start-analysis"
|
||||
? engine.cancelStart(
|
||||
command.idempotencyKey,
|
||||
`tool-cancel-${toolCallId}`,
|
||||
cancellationError(),
|
||||
)
|
||||
: cancelToolWorkflow(runtime, engine, params, toolCallId);
|
||||
void cancellationWork.then(
|
||||
() => rejectCancellation(cancellationError()),
|
||||
() => rejectCancellation(cancellationError()),
|
||||
);
|
||||
};
|
||||
executionSignal.addEventListener("abort", onAbort, { once: true });
|
||||
if (executionSignal.aborted) onAbort();
|
||||
let view: WorkflowView;
|
||||
try {
|
||||
view = await Promise.race([operation, cancellation]);
|
||||
if (executionSignal.aborted) {
|
||||
await cancellationWork;
|
||||
throw cancellationError();
|
||||
}
|
||||
} catch (error) {
|
||||
if (params.action === "inspect" && params.runId) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Workflow Run ${params.runId} could not be loaded: ${reason}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
executionSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
const details = compactWorkflowDetails(
|
||||
params.action,
|
||||
view,
|
||||
ctx.sessionManager.getSessionId(),
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: resultText(details, deriveRunLabel(view)) },
|
||||
],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { CURSOR_MARKER, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
|
||||
import {
|
||||
createPlanReaderModel,
|
||||
createPlanReaderState,
|
||||
reducePlanReaderState,
|
||||
renderPlanReader,
|
||||
sanitizeTerminalText,
|
||||
workflowEditor,
|
||||
workflowInput,
|
||||
WorkflowSelectionOverlay,
|
||||
} from "./tui.ts";
|
||||
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
};
|
||||
|
||||
const plan = [
|
||||
"# Workflow plan",
|
||||
"intro",
|
||||
"## First section",
|
||||
"first body",
|
||||
"### Detail",
|
||||
"detail body",
|
||||
"## Last section",
|
||||
"last body",
|
||||
].join("\n");
|
||||
|
||||
test("selection overlay windows long lists, stays width-safe, and returns stable values", () => {
|
||||
let selected: string | undefined;
|
||||
const overlay = new WorkflowSelectionOverlay(
|
||||
"Workflow Runs",
|
||||
Array.from({ length: 30 }, (_, index) => ({
|
||||
value: `run-${index}`,
|
||||
label: `feature-${index}`,
|
||||
description: index === 0 ? "waiting" : undefined,
|
||||
})),
|
||||
theme as never,
|
||||
(value) => { selected = value; },
|
||||
);
|
||||
const initial = overlay.render(48);
|
||||
assert.ok(initial.some((line) => line.includes("1-12 of 30")));
|
||||
assert.ok(initial.every((line) => visibleWidth(line) <= 48));
|
||||
overlay.handleInput("\x1b[F");
|
||||
const last = overlay.render(48);
|
||||
assert.ok(last.some((line) => line.includes("19-30 of 30")));
|
||||
overlay.handleInput("\r");
|
||||
assert.equal(selected, "run-29");
|
||||
});
|
||||
|
||||
test("selection overlay budgets wrapped descriptions for short narrow terminals", () => {
|
||||
let selected: string | undefined;
|
||||
const overlay = new WorkflowSelectionOverlay(
|
||||
"Short terminal",
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
value: `choice-${index}`,
|
||||
label: `choice-${index}`,
|
||||
description:
|
||||
index === 19
|
||||
? "A long selected description that wraps repeatedly without hiding navigation controls"
|
||||
: undefined,
|
||||
})),
|
||||
theme as never,
|
||||
(value) => { selected = value; },
|
||||
8,
|
||||
);
|
||||
overlay.handleInput("\x1b[F");
|
||||
const lines = overlay.render(28);
|
||||
assert.ok(lines.length <= 8);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 28));
|
||||
assert.ok(lines.some((line) => line.includes("> choice-19")));
|
||||
assert.ok(lines.some((line) => line.includes("enter select")));
|
||||
overlay.handleInput("\x1b[A");
|
||||
assert.ok(overlay.render(28).some((line) => line.includes("> choice-18")));
|
||||
overlay.handleInput("\r");
|
||||
assert.equal(selected, "choice-18");
|
||||
});
|
||||
|
||||
type InteractiveOverlay = Component & Partial<Focusable>;
|
||||
type OverlayFactory = (
|
||||
tui: TUI,
|
||||
overlayTheme: Theme,
|
||||
keybindings: object,
|
||||
done: (value: unknown) => void,
|
||||
) => InteractiveOverlay;
|
||||
|
||||
function interactionContext(
|
||||
interact: (component: InteractiveOverlay) => void,
|
||||
rows = 24,
|
||||
) {
|
||||
const tui = {
|
||||
requestRender() {},
|
||||
terminal: { columns: 80, rows },
|
||||
} as unknown as TUI;
|
||||
return {
|
||||
mode: "tui",
|
||||
ui: {
|
||||
async custom(factory: OverlayFactory) {
|
||||
let completed: unknown;
|
||||
const component = factory(
|
||||
tui,
|
||||
theme as unknown as Theme,
|
||||
{},
|
||||
(value) => { completed = value; },
|
||||
);
|
||||
interact(component);
|
||||
return completed;
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
test("input overlay propagates focus, renders a cursor marker, and handles input", async () => {
|
||||
let unfocused = "";
|
||||
let focused = "";
|
||||
const result = await workflowInput(
|
||||
interactionContext((component) => {
|
||||
unfocused = component.render(48).join("\n");
|
||||
component.focused = true;
|
||||
component.handleInput?.("approved");
|
||||
focused = component.render(48).join("\n");
|
||||
component.handleInput?.("\r");
|
||||
}),
|
||||
"Answer",
|
||||
"Provide a value",
|
||||
);
|
||||
assert.equal(unfocused.includes(CURSOR_MARKER), false);
|
||||
assert.equal(focused.includes(CURSOR_MARKER), true);
|
||||
assert.match(focused, /approved/);
|
||||
assert.equal(result, "approved");
|
||||
});
|
||||
|
||||
test("input overlay keeps focused input and controls visible below a long prompt", async () => {
|
||||
const prompt = Array.from(
|
||||
{ length: 20 },
|
||||
(_, index) => `long prompt segment ${index}`,
|
||||
).join(" ");
|
||||
let unfocused = "";
|
||||
let focused = "";
|
||||
const result = await workflowInput(
|
||||
interactionContext((component) => {
|
||||
unfocused = component.render(40).join("\n");
|
||||
component.focused = true;
|
||||
component.handleInput?.("yes");
|
||||
focused = component.render(40).join("\n");
|
||||
component.handleInput?.("\r");
|
||||
}, 8),
|
||||
"Short terminal",
|
||||
prompt,
|
||||
);
|
||||
assert.ok(unfocused.split("\n").length <= 5);
|
||||
assert.equal(unfocused.includes(CURSOR_MARKER), false);
|
||||
assert.ok(focused.split("\n").length <= 5);
|
||||
assert.ok(focused.split("\n").every((line) => visibleWidth(line) <= 40));
|
||||
assert.equal(focused.includes(CURSOR_MARKER), true);
|
||||
assert.match(focused, /yes/);
|
||||
assert.match(focused, /enter submit esc back/);
|
||||
assert.match(focused, /…/);
|
||||
assert.equal(result, "yes");
|
||||
});
|
||||
|
||||
test("editor overlay propagates focus and Escape resolves through the wrapper", async () => {
|
||||
let focused = "";
|
||||
const result = await workflowEditor(
|
||||
interactionContext((component) => {
|
||||
component.focused = true;
|
||||
component.handleInput?.("x");
|
||||
focused = component.render(56).join("\n");
|
||||
component.handleInput?.("\x1b");
|
||||
}),
|
||||
"Feedback",
|
||||
"Revise the plan",
|
||||
"draft",
|
||||
);
|
||||
assert.equal(focused.includes(CURSOR_MARKER), true);
|
||||
assert.match(focused, /draft/);
|
||||
assert.equal(result, undefined);
|
||||
});
|
||||
|
||||
test("plan reader parses H1-H3 and tracks bounded navigation state", () => {
|
||||
const model = createPlanReaderModel("example", plan);
|
||||
assert.deepEqual(
|
||||
model.headings.map(({ level, text, line }) => ({ level, text, line })),
|
||||
[
|
||||
{ level: 1, text: "Workflow plan", line: 0 },
|
||||
{ level: 2, text: "First section", line: 2 },
|
||||
{ level: 3, text: "Detail", line: 4 },
|
||||
{ level: 2, text: "Last section", line: 6 },
|
||||
],
|
||||
);
|
||||
let state = createPlanReaderState();
|
||||
state = reducePlanReaderState(model, state, "up", { width: 60, height: 24 });
|
||||
assert.equal(state.topRow, 0);
|
||||
state = reducePlanReaderState(model, state, "page-down", { width: 60, height: 12 });
|
||||
assert.equal(state.topRow, 7);
|
||||
state = reducePlanReaderState(model, state, "home", { width: 60, height: 12 });
|
||||
assert.equal(state.topRow, 0);
|
||||
state = reducePlanReaderState(model, state, "end", { width: 60, height: 24 });
|
||||
assert.equal(state.topRow, 7);
|
||||
state = reducePlanReaderState(model, state, "down", { width: 60, height: 24 });
|
||||
assert.equal(state.topRow, 7);
|
||||
});
|
||||
|
||||
test("plan reader renders outline at 80 columns and one pane below 80", () => {
|
||||
const model = createPlanReaderModel("example", plan);
|
||||
const wide = renderPlanReader(model, { topRow: 4 }, { width: 80, height: 24 });
|
||||
assert.ok(wide.some((line) => line.includes("> Detail")));
|
||||
assert.ok(wide.some((line) => line.includes("detail body")));
|
||||
assert.ok(wide.every((line) => visibleWidth(line) <= 80));
|
||||
assert.ok(wide.length <= 24);
|
||||
|
||||
const narrow = renderPlanReader(model, { topRow: 4 }, { width: 60, height: 12 });
|
||||
assert.match(narrow[0] ?? "", /Detail/);
|
||||
assert.equal(narrow.some((line) => line.includes("> Detail")), false);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 60));
|
||||
assert.ok(narrow.length <= 12);
|
||||
});
|
||||
|
||||
test("wide outline windows enough headings to keep the current section visible", () => {
|
||||
const contents = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) => `## Section ${index}\nbody ${index}`,
|
||||
).join("\n");
|
||||
const model = createPlanReaderModel("many sections", contents);
|
||||
const lines = renderPlanReader(
|
||||
model,
|
||||
{ topRow: 70 },
|
||||
{ width: 80, height: 12 },
|
||||
);
|
||||
assert.ok(lines.some((line) => />\s+Section 35/.test(line)));
|
||||
assert.equal(lines.some((line) => line.includes("Section 0")), false);
|
||||
});
|
||||
|
||||
test("plan reader reports truthful minimum size below either boundary", () => {
|
||||
const model = createPlanReaderModel("example", plan);
|
||||
for (const dimensions of [
|
||||
{ width: 47, height: 11 },
|
||||
{ width: 47, height: 24 },
|
||||
{ width: 80, height: 11 },
|
||||
]) {
|
||||
const lines = renderPlanReader(model, createPlanReaderState(), dimensions);
|
||||
assert.ok(lines.some((line) => line.includes("needs 48x12")));
|
||||
assert.ok(
|
||||
lines.some((line) =>
|
||||
line.includes(`current terminal is ${dimensions.width}x${dimensions.height}`),
|
||||
),
|
||||
);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= dimensions.width));
|
||||
assert.ok(lines.length <= dimensions.height);
|
||||
}
|
||||
});
|
||||
|
||||
test("reader sanitizes terminal control sequences before rendering", () => {
|
||||
const unsafe = "# Safe\n\x1b[31mred\x1b[0m\n\x1b]8;;https://bad.invalid\x07link\x1b]8;;\x07\nend\u0000";
|
||||
const sanitized = sanitizeTerminalText(unsafe);
|
||||
assert.equal(sanitized.includes("\x1b"), false);
|
||||
assert.equal(sanitized.includes("\u0000"), false);
|
||||
const model = createPlanReaderModel("unsafe", unsafe);
|
||||
const lines = renderPlanReader(model, createPlanReaderState(), { width: 48, height: 12 });
|
||||
assert.equal(lines.join("\n").includes("\x1b"), false);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 48));
|
||||
});
|
||||
|
||||
test("plan reader reaches every wrapped display row before following content", () => {
|
||||
const longLine = Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) => `chunk-${String(index).padStart(2, "0")}`,
|
||||
).join(" ");
|
||||
const model = createPlanReaderModel(
|
||||
"wrapped rows",
|
||||
`# Wrapped section\n${longLine}\nfollowing-content`,
|
||||
);
|
||||
const expectedTopRows = [
|
||||
"# Wrapped section",
|
||||
...wrapTextWithAnsi(longLine, 46),
|
||||
"following-content",
|
||||
];
|
||||
let state = createPlanReaderState();
|
||||
for (const expected of expectedTopRows) {
|
||||
const frame = renderPlanReader(model, state, { width: 48, height: 12 });
|
||||
assert.equal(frame[2]?.trim(), expected);
|
||||
assert.match(frame[0] ?? "", /Wrapped sect/);
|
||||
state = reducePlanReaderState(
|
||||
model,
|
||||
state,
|
||||
"down",
|
||||
{ width: 48, height: 12 },
|
||||
);
|
||||
}
|
||||
assert.ok(expectedTopRows.length > 5);
|
||||
});
|
||||
|
||||
test("reader only includes the active viewport near document end", () => {
|
||||
const contents = Array.from({ length: 200 }, (_, index) => `line-${index}`).join("\n");
|
||||
const model = createPlanReaderModel("large", contents);
|
||||
const lines = renderPlanReader(model, { topRow: 190 }, { width: 60, height: 12 });
|
||||
assert.ok(lines.some((line) => line.includes("line-190")));
|
||||
assert.equal(lines.some((line) => line.includes("line-20")), false);
|
||||
assert.ok(lines.length <= 12);
|
||||
});
|
||||
@@ -0,0 +1,702 @@
|
||||
import type {
|
||||
ExtensionContext,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
Editor,
|
||||
type EditorTheme,
|
||||
Input,
|
||||
Key,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type Focusable,
|
||||
type TUI,
|
||||
} from "@earendil-works/pi-tui";
|
||||
|
||||
export interface OverlayChoice<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function sanitizeLine(value: string) {
|
||||
return value
|
||||
.replace(
|
||||
/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?|[PX^_][\s\S]*?\x1b\\|.)/g,
|
||||
"",
|
||||
)
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
|
||||
.replaceAll("\t", " ");
|
||||
}
|
||||
|
||||
export function sanitizeTerminalText(value: string) {
|
||||
return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").map(sanitizeLine).join("\n");
|
||||
}
|
||||
|
||||
function fit(value: string, width: number) {
|
||||
const fitted = truncateToWidth(value, Math.max(1, width), "");
|
||||
return value.includes("\x1b") ? fitted : sanitizeLine(fitted);
|
||||
}
|
||||
|
||||
function pad(value: string, width: number) {
|
||||
const bounded = fit(value, width);
|
||||
return `${bounded}${" ".repeat(Math.max(0, width - visibleWidth(bounded)))}`;
|
||||
}
|
||||
|
||||
function card(
|
||||
theme: Theme,
|
||||
title: string,
|
||||
contents: string[],
|
||||
width: number,
|
||||
) {
|
||||
const innerWidth = Math.max(1, width - 2);
|
||||
const safeTitle = fit(` ${sanitizeLine(title)} `, innerWidth);
|
||||
const titleWidth = visibleWidth(safeTitle);
|
||||
const trailing = Math.max(0, innerWidth - titleWidth);
|
||||
return [
|
||||
`${theme.fg("border", "╭")}${theme.fg("accent", safeTitle)}${theme.fg("border", `${"─".repeat(trailing)}╮`)}`,
|
||||
...contents.map(
|
||||
(line) => `${theme.fg("border", "│")}${pad(line, innerWidth)}${theme.fg("border", "│")}`,
|
||||
),
|
||||
theme.fg("border", `╰${"─".repeat(innerWidth)}╯`),
|
||||
].map((line) => fit(line, width));
|
||||
}
|
||||
|
||||
export class WorkflowSelectionOverlay<T> implements Component {
|
||||
private selected = 0;
|
||||
private readonly title: string;
|
||||
private readonly choices: OverlayChoice<T>[];
|
||||
private readonly theme: Theme;
|
||||
private readonly done: (value: T | undefined) => void;
|
||||
private readonly maxRows: number;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
choices: OverlayChoice<T>[],
|
||||
theme: Theme,
|
||||
done: (value: T | undefined) => void,
|
||||
maxRows = 24,
|
||||
) {
|
||||
this.title = title;
|
||||
this.choices = choices;
|
||||
this.theme = theme;
|
||||
this.done = done;
|
||||
this.maxRows = Math.max(4, Math.floor(maxRows));
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.done(undefined);
|
||||
} else if (matchesKey(data, Key.up)) {
|
||||
this.selected = Math.max(0, this.selected - 1);
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selected = Math.min(this.choices.length - 1, this.selected + 1);
|
||||
} else if (matchesKey(data, Key.home)) {
|
||||
this.selected = 0;
|
||||
} else if (matchesKey(data, Key.end)) {
|
||||
this.selected = Math.max(0, this.choices.length - 1);
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const choice = this.choices[this.selected];
|
||||
if (choice) this.done(choice.value);
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
const bodyRows = Math.max(2, this.maxRows - 2);
|
||||
const showLeadingSpace = bodyRows >= 5;
|
||||
const showPagination = this.choices.length > 1 && bodyRows >= 3;
|
||||
const selectedChoice = this.choices[this.selected];
|
||||
const descriptionWidth = Math.max(1, width - 8);
|
||||
const selectedDescription = selectedChoice?.description
|
||||
? sanitizeTerminalText(selectedChoice.description)
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const wrapped = wrapTextWithAnsi(line, descriptionWidth);
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
})
|
||||
: [];
|
||||
const fixedRows = 1 + Number(showLeadingSpace) + Number(showPagination);
|
||||
const choiceRows = Math.max(1, bodyRows - fixedRows);
|
||||
const descriptionRows = Math.min(
|
||||
selectedDescription.length,
|
||||
Math.max(0, Math.floor((choiceRows - 1) / 2)),
|
||||
);
|
||||
const maxVisible = Math.max(
|
||||
1,
|
||||
Math.min(12, this.choices.length || 1, choiceRows - descriptionRows),
|
||||
);
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selected - Math.floor(maxVisible / 2),
|
||||
Math.max(0, this.choices.length - maxVisible),
|
||||
),
|
||||
);
|
||||
const body: string[] = showLeadingSpace ? [""] : [];
|
||||
for (const [offset, choice] of this.choices.slice(start, start + maxVisible).entries()) {
|
||||
const index = start + offset;
|
||||
const selected = index === this.selected;
|
||||
const text = `${selected ? "> " : " "}${sanitizeLine(choice.label)}`;
|
||||
body.push(
|
||||
selected
|
||||
? this.theme.bg("selectedBg", this.theme.fg("text", text))
|
||||
: this.theme.fg("text", text),
|
||||
);
|
||||
if (selected) {
|
||||
for (const line of selectedDescription.slice(0, descriptionRows)) {
|
||||
body.push(this.theme.fg("muted", ` ${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.choices.length === 0) {
|
||||
body.push(this.theme.fg("muted", " No choices available"));
|
||||
}
|
||||
if (showPagination && this.choices.length > maxVisible) {
|
||||
body.push(
|
||||
this.theme.fg(
|
||||
"dim",
|
||||
` ${start + 1}-${Math.min(this.choices.length, start + maxVisible)} of ${this.choices.length}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
body.push(this.theme.fg("dim", " ↑↓ navigate enter select esc back"));
|
||||
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
|
||||
}
|
||||
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
abstract class FocusedTextOverlay implements Component, Focusable {
|
||||
private _focused = false;
|
||||
|
||||
abstract readonly child: Input | Editor;
|
||||
|
||||
get focused() {
|
||||
return this._focused;
|
||||
}
|
||||
|
||||
set focused(value: boolean) {
|
||||
this._focused = value;
|
||||
this.child.focused = value;
|
||||
}
|
||||
|
||||
abstract render(width: number): string[];
|
||||
abstract handleInput(data: string): void;
|
||||
abstract invalidate(): void;
|
||||
}
|
||||
|
||||
class WorkflowInputOverlay extends FocusedTextOverlay {
|
||||
readonly child = new Input();
|
||||
private readonly title: string;
|
||||
private readonly prompt: string;
|
||||
private readonly theme: Theme;
|
||||
private readonly maxRows: number;
|
||||
|
||||
constructor(
|
||||
title: string,
|
||||
prompt: string,
|
||||
theme: Theme,
|
||||
done: (value: string | undefined) => void,
|
||||
maxRows: number,
|
||||
) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.prompt = prompt;
|
||||
this.theme = theme;
|
||||
this.maxRows = Math.max(4, Math.floor(maxRows));
|
||||
this.child.onSubmit = (value) => done(value);
|
||||
this.child.onEscape = () => done(undefined);
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
this.child.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
const innerWidth = Math.max(1, width - 4);
|
||||
const wrappedPrompt = sanitizeTerminalText(this.prompt)
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const wrapped = wrapTextWithAnsi(line, innerWidth);
|
||||
return wrapped.length > 0 ? wrapped : [""];
|
||||
});
|
||||
const bodyRows = this.maxRows - 2;
|
||||
const showLeadingSpace = bodyRows >= 5;
|
||||
const promptRows = Math.max(0, bodyRows - 2 - Number(showLeadingSpace));
|
||||
const prompt = wrappedPrompt.slice(0, promptRows);
|
||||
if (wrappedPrompt.length > promptRows && prompt.length > 0) {
|
||||
const last = prompt.length - 1;
|
||||
prompt[last] = fit(
|
||||
`${truncateToWidth(prompt[last] ?? "", Math.max(1, innerWidth - 1), "")}…`,
|
||||
innerWidth,
|
||||
);
|
||||
}
|
||||
const body = [
|
||||
...(showLeadingSpace ? [""] : []),
|
||||
...prompt.map((line) => ` ${line}`),
|
||||
` ${this.child.render(innerWidth)[0] ?? ""}`,
|
||||
this.theme.fg("dim", " enter submit esc back"),
|
||||
];
|
||||
return card(this.theme, this.title, body, width).slice(0, this.maxRows);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.child.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowEditorOverlay extends FocusedTextOverlay {
|
||||
readonly child: Editor;
|
||||
private readonly title: string;
|
||||
private readonly prompt: string;
|
||||
private readonly theme: Theme;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
title: string,
|
||||
prompt: string,
|
||||
initial: string,
|
||||
theme: Theme,
|
||||
done: (value: string | undefined) => void,
|
||||
) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.prompt = prompt;
|
||||
this.theme = theme;
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (value) => theme.fg("borderAccent", value),
|
||||
selectList: {
|
||||
selectedPrefix: (value) => theme.fg("accent", value),
|
||||
selectedText: (value) => theme.fg("accent", value),
|
||||
description: (value) => theme.fg("muted", value),
|
||||
scrollInfo: (value) => theme.fg("dim", value),
|
||||
noMatch: (value) => theme.fg("warning", value),
|
||||
},
|
||||
};
|
||||
this.child = new Editor(tui, editorTheme, { paddingX: 0 });
|
||||
this.child.setText(initial);
|
||||
this.child.onSubmit = (value) => done(value);
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape)) return;
|
||||
this.child.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
const innerWidth = Math.max(1, width - 4);
|
||||
const prompt = wrapTextWithAnsi(sanitizeLine(this.prompt), innerWidth);
|
||||
return card(
|
||||
this.theme,
|
||||
this.title,
|
||||
["", ...prompt.map((line) => ` ${line}`), "", ...this.child.render(innerWidth).map((line) => ` ${line}`), "", this.theme.fg("dim", " enter submit shift+enter newline esc back")],
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.child.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
function assertTui(ctx: ExtensionContext) {
|
||||
if (ctx.mode !== "tui") {
|
||||
throw new Error("workflow overlays require TUI mode");
|
||||
}
|
||||
}
|
||||
|
||||
export async function workflowSelect<T>(
|
||||
ctx: ExtensionContext,
|
||||
title: string,
|
||||
choices: OverlayChoice<T>[],
|
||||
) {
|
||||
assertTui(ctx);
|
||||
return ctx.ui.custom<T | undefined>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
const overlay = new WorkflowSelectionOverlay(
|
||||
title,
|
||||
choices,
|
||||
theme,
|
||||
done,
|
||||
Math.floor(tui.terminal.rows * 0.8),
|
||||
);
|
||||
return {
|
||||
render: (width) => overlay.render(width),
|
||||
invalidate: () => overlay.invalidate(),
|
||||
handleInput: (data) => {
|
||||
overlay.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
anchor: "center",
|
||||
width: "70%",
|
||||
minWidth: 36,
|
||||
maxHeight: "80%",
|
||||
margin: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function workflowConfirm(
|
||||
ctx: ExtensionContext,
|
||||
title: string,
|
||||
message: string,
|
||||
) {
|
||||
return workflowSelect(ctx, title, [
|
||||
{ value: true, label: "Confirm", description: message },
|
||||
{ value: false, label: "Cancel" },
|
||||
]).then((value) => value === true);
|
||||
}
|
||||
|
||||
export async function workflowInput(
|
||||
ctx: ExtensionContext,
|
||||
title: string,
|
||||
prompt: string,
|
||||
) {
|
||||
assertTui(ctx);
|
||||
return ctx.ui.custom<string | undefined>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
const overlay = new WorkflowInputOverlay(
|
||||
title,
|
||||
prompt,
|
||||
theme,
|
||||
done,
|
||||
Math.max(4, Math.floor(tui.terminal.rows * 0.7)),
|
||||
);
|
||||
return {
|
||||
get focused() {
|
||||
return overlay.focused;
|
||||
},
|
||||
set focused(value: boolean) {
|
||||
overlay.focused = value;
|
||||
},
|
||||
render: (width) => overlay.render(width),
|
||||
invalidate: () => overlay.invalidate(),
|
||||
handleInput: (data) => {
|
||||
overlay.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "center", width: "70%", minWidth: 36, maxHeight: "70%", margin: 1 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function workflowEditor(
|
||||
ctx: ExtensionContext,
|
||||
title: string,
|
||||
prompt: string,
|
||||
initial = "",
|
||||
) {
|
||||
assertTui(ctx);
|
||||
return ctx.ui.custom<string | undefined>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
const overlay = new WorkflowEditorOverlay(tui, title, prompt, initial, theme, done);
|
||||
return {
|
||||
get focused() {
|
||||
return overlay.focused;
|
||||
},
|
||||
set focused(value: boolean) {
|
||||
overlay.focused = value;
|
||||
},
|
||||
render: (width) => overlay.render(width),
|
||||
invalidate: () => overlay.invalidate(),
|
||||
handleInput: (data) => {
|
||||
if (matchesKey(data, Key.escape)) done(undefined);
|
||||
else overlay.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: { anchor: "center", width: "80%", minWidth: 40, maxHeight: "80%", margin: 1 },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface PlanHeading {
|
||||
level: 1 | 2 | 3;
|
||||
text: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface PlanReaderModel {
|
||||
title: string;
|
||||
lines: string[];
|
||||
headings: PlanHeading[];
|
||||
}
|
||||
|
||||
export interface PlanReaderState {
|
||||
topRow: number;
|
||||
}
|
||||
|
||||
export type PlanReaderAction =
|
||||
| "up"
|
||||
| "down"
|
||||
| "page-up"
|
||||
| "page-down"
|
||||
| "home"
|
||||
| "end";
|
||||
|
||||
interface PlanDisplayRow {
|
||||
text: string;
|
||||
sourceLine: number;
|
||||
}
|
||||
|
||||
export function createPlanReaderModel(title: string, contents: string): PlanReaderModel {
|
||||
const lines = sanitizeTerminalText(contents).split("\n");
|
||||
const headings = lines.flatMap((line, index): PlanHeading[] => {
|
||||
const match = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
|
||||
return match
|
||||
? [{ level: match[1]!.length as 1 | 2 | 3, text: match[2]!, line: index }]
|
||||
: [];
|
||||
});
|
||||
return { title: sanitizeLine(title), lines, headings };
|
||||
}
|
||||
|
||||
export function createPlanReaderState(): PlanReaderState {
|
||||
return { topRow: 0 };
|
||||
}
|
||||
|
||||
function readerBodyHeight(height: number) {
|
||||
return Math.max(1, height - 4);
|
||||
}
|
||||
|
||||
function readerOutlineWidth(width: number) {
|
||||
return Math.max(24, Math.min(32, Math.floor(width * 0.34)));
|
||||
}
|
||||
|
||||
function readerDocumentWidth(width: number) {
|
||||
return width < 80
|
||||
? Math.max(1, width - 2)
|
||||
: Math.max(1, width - readerOutlineWidth(width) - 3);
|
||||
}
|
||||
|
||||
function displayRows(model: PlanReaderModel, width: number) {
|
||||
return model.lines.flatMap((line, sourceLine): PlanDisplayRow[] => {
|
||||
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
|
||||
return (wrapped.length > 0 ? wrapped : [""]).map((text) => ({
|
||||
text,
|
||||
sourceLine,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function reducePlanReaderState(
|
||||
model: PlanReaderModel,
|
||||
state: PlanReaderState,
|
||||
action: PlanReaderAction,
|
||||
dimensions: { width: number; height: number },
|
||||
): PlanReaderState {
|
||||
const rows = displayRows(model, readerDocumentWidth(dimensions.width));
|
||||
const last = Math.max(0, rows.length - 1);
|
||||
const page = Math.max(1, readerBodyHeight(dimensions.height) - 1);
|
||||
const delta =
|
||||
action === "up"
|
||||
? -1
|
||||
: action === "down"
|
||||
? 1
|
||||
: action === "page-up"
|
||||
? -page
|
||||
: action === "page-down"
|
||||
? page
|
||||
: 0;
|
||||
const topRow =
|
||||
action === "home"
|
||||
? 0
|
||||
: action === "end"
|
||||
? last
|
||||
: Math.max(0, Math.min(last, state.topRow + delta));
|
||||
return { topRow };
|
||||
}
|
||||
|
||||
function currentHeading(model: PlanReaderModel, sourceLine: number) {
|
||||
return [...model.headings].reverse().find((heading) => heading.line <= sourceLine);
|
||||
}
|
||||
|
||||
function viewportRows(
|
||||
rows: PlanDisplayRow[],
|
||||
topRow: number,
|
||||
height: number,
|
||||
) {
|
||||
return rows.slice(topRow, topRow + readerBodyHeight(height));
|
||||
}
|
||||
|
||||
export function renderPlanReader(
|
||||
model: PlanReaderModel,
|
||||
state: PlanReaderState,
|
||||
dimensions: { width: number; height: number },
|
||||
theme?: Theme,
|
||||
) {
|
||||
const { width, height } = dimensions;
|
||||
if (width < 48 || height < 12) {
|
||||
const lines = [
|
||||
"",
|
||||
"terminal too small",
|
||||
"",
|
||||
"workflow plan needs 48x12",
|
||||
`current terminal is ${width}x${height}`,
|
||||
"",
|
||||
"resize the terminal or press esc",
|
||||
];
|
||||
const leftPad = Math.max(0, Math.floor((width - Math.max(...lines.map(visibleWidth))) / 2));
|
||||
return lines.map((line) => fit(`${" ".repeat(leftPad)}${line}`, width)).slice(0, height);
|
||||
}
|
||||
const documentWidth = readerDocumentWidth(width);
|
||||
const rows = displayRows(model, documentWidth);
|
||||
const topRow = Math.max(0, Math.min(rows.length - 1, state.topRow));
|
||||
const sourceLine = rows[topRow]?.sourceLine ?? 0;
|
||||
const heading = currentHeading(model, sourceLine);
|
||||
const position = `row ${Math.min(rows.length, topRow + 1)}/${rows.length}`;
|
||||
const headingLabel = width < 80 && heading ? ` · ${heading.text}` : "";
|
||||
const header = fit(` workflow plan · ${model.title}${headingLabel} · ${position}`, width);
|
||||
const footer = fit(" ↑↓ scroll pgup/pgdn page home/end document esc back", width);
|
||||
const bodyHeight = readerBodyHeight(height);
|
||||
const paint = (token: "accent" | "dim" | "mdHeading" | "muted" | "text", value: string) =>
|
||||
theme ? theme.fg(token, value) : value;
|
||||
const viewport = viewportRows(rows, topRow, height);
|
||||
if (width < 80) {
|
||||
const body = viewport.map((row) => {
|
||||
const token = /^#{1,3}\s/.test(row.text) ? "mdHeading" as const : "text" as const;
|
||||
return fit(` ${paint(token, row.text)}`, width);
|
||||
});
|
||||
while (body.length < bodyHeight) body.push("");
|
||||
return [paint("accent", header), "", ...body.slice(0, bodyHeight), paint("dim", footer)].slice(0, height);
|
||||
}
|
||||
const outlineWidth = readerOutlineWidth(width);
|
||||
const gap = 3;
|
||||
const body = viewport.map((row) => row.text);
|
||||
const selectedHeading = heading?.line;
|
||||
const selectedHeadingIndex = Math.max(
|
||||
0,
|
||||
model.headings.findIndex((entry) => entry.line === selectedHeading),
|
||||
);
|
||||
const outlineStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
selectedHeadingIndex - Math.floor(bodyHeight / 2),
|
||||
Math.max(0, model.headings.length - bodyHeight),
|
||||
),
|
||||
);
|
||||
const outline = model.headings
|
||||
.slice(outlineStart, outlineStart + bodyHeight)
|
||||
.map((entry) => {
|
||||
const prefix = entry.line === selectedHeading ? "> " : " ";
|
||||
const indent = " ".repeat(entry.level - 1);
|
||||
const text = `${prefix}${indent}${entry.text}`;
|
||||
return entry.line === selectedHeading
|
||||
? paint("accent", text)
|
||||
: paint(entry.level === 1 ? "text" : "muted", text);
|
||||
});
|
||||
const renderedRows = Array.from({ length: bodyHeight }, (_, index) => {
|
||||
const left = pad(outline[index] ?? "", outlineWidth);
|
||||
const right = fit(body[index] ?? "", documentWidth);
|
||||
return fit(`${left}${" ".repeat(gap)}${right}`, width);
|
||||
});
|
||||
return [paint("accent", header), "", ...renderedRows, paint("dim", footer)].slice(0, height);
|
||||
}
|
||||
|
||||
class PlanReaderComponent implements Component {
|
||||
private state = createPlanReaderState();
|
||||
private width: number;
|
||||
private readonly tui: TUI;
|
||||
private readonly model: PlanReaderModel;
|
||||
private readonly theme: Theme;
|
||||
private readonly done: () => void;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
model: PlanReaderModel,
|
||||
theme: Theme,
|
||||
done: () => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.width = tui.terminal.columns;
|
||||
this.model = model;
|
||||
this.theme = theme;
|
||||
this.done = done;
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.done();
|
||||
return;
|
||||
}
|
||||
const action = matchesKey(data, Key.up)
|
||||
? "up"
|
||||
: matchesKey(data, Key.down)
|
||||
? "down"
|
||||
: matchesKey(data, Key.pageUp)
|
||||
? "page-up"
|
||||
: matchesKey(data, Key.pageDown)
|
||||
? "page-down"
|
||||
: matchesKey(data, Key.home)
|
||||
? "home"
|
||||
: matchesKey(data, Key.end)
|
||||
? "end"
|
||||
: undefined;
|
||||
if (action) {
|
||||
this.state = reducePlanReaderState(
|
||||
this.model,
|
||||
this.state,
|
||||
action,
|
||||
{ width: this.width, height: this.tui.terminal.rows },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
this.width = width;
|
||||
return renderPlanReader(
|
||||
this.model,
|
||||
this.state,
|
||||
{ width, height: this.tui.terminal.rows },
|
||||
this.theme,
|
||||
);
|
||||
}
|
||||
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
export async function workflowPlanReader(
|
||||
ctx: ExtensionContext,
|
||||
title: string,
|
||||
contents: string,
|
||||
) {
|
||||
assertTui(ctx);
|
||||
const model = createPlanReaderModel(title, contents);
|
||||
await ctx.ui.custom<void>(
|
||||
(tui, theme, _keybindings, done) => {
|
||||
const reader = new PlanReaderComponent(tui, model, theme, done);
|
||||
return {
|
||||
render: (width) => reader.render(width),
|
||||
invalidate: () => reader.invalidate(),
|
||||
handleInput: (data) => {
|
||||
reader.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
anchor: "top-left",
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { WorkflowFacts } from "./domain.ts";
|
||||
import { enforceUiImpact } from "./ui-policy.ts";
|
||||
|
||||
function facts(overrides: Partial<WorkflowFacts> = {}): WorkflowFacts {
|
||||
return {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
requestTerms: [],
|
||||
candidatePaths: [],
|
||||
languages: ["typescript"],
|
||||
tags: [],
|
||||
riskTags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("objective non-trivial UI signals cannot be downgraded", () => {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "minor",
|
||||
plannerEvidence: ["Planner expected a small change"],
|
||||
affectedSurfaces: ["Application shell"],
|
||||
facts: facts({ requestTerms: ["redesign", "navigation"] }),
|
||||
});
|
||||
assert.equal(result.impact, "non-trivial");
|
||||
assert.equal(result.objectiveImpact, "non-trivial");
|
||||
assert.ok(result.evidence.some((entry) => entry.includes("redesign")));
|
||||
});
|
||||
|
||||
test("layout paths and accessibility evidence require prototypes", () => {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "none",
|
||||
plannerEvidence: [],
|
||||
affectedSurfaces: [],
|
||||
facts: facts({
|
||||
candidatePaths: ["src/routes/layout.svelte"],
|
||||
riskTags: ["accessibility"],
|
||||
}),
|
||||
});
|
||||
assert.equal(result.impact, "non-trivial");
|
||||
assert.equal(result.evidence.length, 2);
|
||||
});
|
||||
|
||||
test("common pages, forms, modals, sidebars, and settings changes cannot bypass prototypes", () => {
|
||||
for (const signal of ["page", "form", "modal", "sidebar", "settings"]) {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "minor",
|
||||
plannerEvidence: [],
|
||||
affectedSurfaces: [],
|
||||
facts: facts({ requestTerms: ["add", signal] }),
|
||||
});
|
||||
assert.equal(result.impact, "non-trivial", signal);
|
||||
}
|
||||
assert.equal(
|
||||
enforceUiImpact({
|
||||
plannerImpact: "minor",
|
||||
plannerEvidence: [],
|
||||
affectedSurfaces: [],
|
||||
facts: facts({ candidatePaths: ["src/routes/account/+page.svelte"] }),
|
||||
}).impact,
|
||||
"non-trivial",
|
||||
);
|
||||
});
|
||||
|
||||
test("backend workflow terminology does not imply a UI prototype", () => {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "none",
|
||||
plannerEvidence: ["Backend orchestration only"],
|
||||
affectedSurfaces: [],
|
||||
facts: facts({
|
||||
requestTerms: ["workflow", "queue"],
|
||||
candidatePaths: ["src/server/workflow.ts"],
|
||||
}),
|
||||
});
|
||||
assert.equal(result.impact, "none");
|
||||
});
|
||||
|
||||
test("configuration files named settings do not imply a UI prototype", () => {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "none",
|
||||
plannerEvidence: ["Configuration inventory only"],
|
||||
affectedSurfaces: [],
|
||||
facts: facts({
|
||||
candidatePaths: [
|
||||
"/home/user/.pi/agent/settings.json",
|
||||
"config/settings.yaml",
|
||||
],
|
||||
tags: ["configuration", "security"],
|
||||
}),
|
||||
});
|
||||
assert.equal(result.impact, "none");
|
||||
assert.equal(result.objectiveImpact, "none");
|
||||
});
|
||||
|
||||
test("generic frontend evidence does not inflate a minor change", () => {
|
||||
const result = enforceUiImpact({
|
||||
plannerImpact: "minor",
|
||||
plannerEvidence: ["Adds one field"],
|
||||
affectedSurfaces: ["Dashboard"],
|
||||
facts: facts({
|
||||
candidatePaths: ["src/routes/dashboard.ts"],
|
||||
tags: ["frontend", "ui"],
|
||||
}),
|
||||
});
|
||||
assert.equal(result.impact, "minor");
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { UiImpact, WorkflowFacts } from "./domain.ts";
|
||||
|
||||
const IMPACT_RANK: Record<UiImpact, number> = {
|
||||
none: 0,
|
||||
minor: 1,
|
||||
"non-trivial": 2,
|
||||
};
|
||||
|
||||
const NON_TRIVIAL_TERMS = new Set([
|
||||
"accessibility",
|
||||
"a11y",
|
||||
"component",
|
||||
"dialog",
|
||||
"editor",
|
||||
"form",
|
||||
"layout",
|
||||
"modal",
|
||||
"navigation",
|
||||
"onboarding",
|
||||
"page",
|
||||
"redesign",
|
||||
"responsive",
|
||||
"settings",
|
||||
"sidebar",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const NON_TRIVIAL_PATH_PARTS = [
|
||||
"/+page.",
|
||||
"/dialog.",
|
||||
"/form.",
|
||||
"/layout.",
|
||||
"/modal.",
|
||||
"/navigation.",
|
||||
"/nav.",
|
||||
"/settings.",
|
||||
"/shell.",
|
||||
"/sidebar.",
|
||||
"/accessibility/",
|
||||
"/onboarding/",
|
||||
"/settings/",
|
||||
];
|
||||
|
||||
const UI_SOURCE_SUFFIXES = [
|
||||
".astro",
|
||||
".css",
|
||||
".html",
|
||||
".jsx",
|
||||
".less",
|
||||
".scss",
|
||||
".svelte",
|
||||
".tsx",
|
||||
".vue",
|
||||
];
|
||||
|
||||
export interface UiImpactPolicyInput {
|
||||
plannerImpact: UiImpact;
|
||||
plannerEvidence: string[];
|
||||
affectedSurfaces: string[];
|
||||
facts: WorkflowFacts;
|
||||
}
|
||||
|
||||
export interface UiImpactPolicyResult {
|
||||
impact: UiImpact;
|
||||
evidence: string[];
|
||||
affectedSurfaces: string[];
|
||||
objectiveImpact: UiImpact;
|
||||
}
|
||||
|
||||
function objectiveEvidence(facts: WorkflowFacts) {
|
||||
const evidence: string[] = [];
|
||||
for (const term of facts.requestTerms) {
|
||||
if (NON_TRIVIAL_TERMS.has(term.toLowerCase())) {
|
||||
evidence.push(`Request includes non-trivial UI signal: ${term}`);
|
||||
}
|
||||
}
|
||||
for (const candidatePath of facts.candidatePaths) {
|
||||
const normalized = `/${candidatePath.toLowerCase().replaceAll("\\", "/")}`;
|
||||
const isUiSource = UI_SOURCE_SUFFIXES.some((suffix) =>
|
||||
normalized.endsWith(suffix),
|
||||
);
|
||||
if (
|
||||
isUiSource &&
|
||||
NON_TRIVIAL_PATH_PARTS.some((part) => normalized.includes(part))
|
||||
) {
|
||||
evidence.push(`Affected path has non-trivial UI responsibility: ${candidatePath}`);
|
||||
}
|
||||
}
|
||||
for (const tag of [...facts.tags, ...facts.riskTags]) {
|
||||
if (NON_TRIVIAL_TERMS.has(tag.toLowerCase())) {
|
||||
evidence.push(`Repository evidence includes non-trivial UI signal: ${tag}`);
|
||||
}
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
export function enforceUiImpact(input: UiImpactPolicyInput): UiImpactPolicyResult {
|
||||
const objective = objectiveEvidence(input.facts);
|
||||
const objectiveImpact: UiImpact =
|
||||
objective.length > 0 ? "non-trivial" : input.plannerImpact;
|
||||
const impact =
|
||||
IMPACT_RANK[objectiveImpact] > IMPACT_RANK[input.plannerImpact]
|
||||
? objectiveImpact
|
||||
: input.plannerImpact;
|
||||
return {
|
||||
impact,
|
||||
objectiveImpact,
|
||||
evidence: [...new Set([...input.plannerEvidence, ...objective])],
|
||||
affectedSurfaces: [...new Set(input.affectedSurfaces)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import test from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import {
|
||||
ApprovedHostCommandExecutor,
|
||||
ExecFileCommandRunner,
|
||||
FilesystemPrototypeValidator,
|
||||
GitStateProbe,
|
||||
GitWorkspaceManager,
|
||||
INTERNAL_GIT_TIMEOUT_MS,
|
||||
MutationScope,
|
||||
WorkspaceSafetyError,
|
||||
WorkspaceValidationError,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
type CommandRunOptions,
|
||||
} from "./workspace.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function crc32(contents: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of contents) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer) {
|
||||
const name = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.byteLength);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
|
||||
return Buffer.concat([length, name, data, checksum]);
|
||||
}
|
||||
|
||||
function grayscalePng(width: number, height: number, value: number) {
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 0;
|
||||
const rows = Buffer.alloc((width + 1) * height);
|
||||
for (let row = 0; row < height; row++) {
|
||||
rows[row * (width + 1)] = 0;
|
||||
rows.fill(value, row * (width + 1) + 1, (row + 1) * (width + 1));
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk("IHDR", header),
|
||||
pngChunk("IDAT", deflateSync(rows)),
|
||||
pngChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
const directory = await mkdtemp(
|
||||
path.join(tmpdir(), "workflow-workspace-"),
|
||||
);
|
||||
try {
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
readonly calls: Array<{
|
||||
cwd: string;
|
||||
executable: string;
|
||||
args: string[];
|
||||
options?: CommandRunOptions;
|
||||
}> = [];
|
||||
private readonly respond: (args: string[]) => CommandResult;
|
||||
|
||||
constructor(respond: (args: string[]) => CommandResult) {
|
||||
this.respond = respond;
|
||||
}
|
||||
|
||||
async run(
|
||||
cwd: string,
|
||||
executable: string,
|
||||
args: string[],
|
||||
options?: CommandRunOptions,
|
||||
) {
|
||||
this.calls.push({ cwd, executable, args: [...args], options });
|
||||
return this.respond(args);
|
||||
}
|
||||
}
|
||||
|
||||
function result(code = 0, stdout = "", stderr = ""): CommandResult {
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
function prepareResponses(
|
||||
repository: string,
|
||||
options: {
|
||||
dirty?: boolean;
|
||||
unmerged?: boolean;
|
||||
ignored?: boolean;
|
||||
branchExists?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return (args: string[]) => {
|
||||
const command = args.join(" ");
|
||||
if (command === "rev-parse --show-toplevel")
|
||||
return result(0, `${repository}\n`);
|
||||
if (command === "status --porcelain=v1 -z --untracked-files=all") {
|
||||
if (options.unmerged) return result(0, "UU src/conflict.ts\0");
|
||||
return result(
|
||||
0,
|
||||
options.dirty ? " M src/file.ts\0?? src/new-file.ts\0" : "",
|
||||
);
|
||||
}
|
||||
if (command.startsWith("check-ignore ")) {
|
||||
return result(options.ignored === false ? 1 : 0);
|
||||
}
|
||||
if (command.startsWith("show-ref ")) {
|
||||
return result(options.branchExists ? 0 : 1);
|
||||
}
|
||||
if (command === "rev-parse HEAD") return result(0, "abc123\n");
|
||||
if (command.startsWith("worktree add ")) return result();
|
||||
throw new Error(`Unexpected Git command: ${command}`);
|
||||
};
|
||||
}
|
||||
|
||||
test("mutation ownership rejects traversal, sibling prefixes, and symlinks", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const root = path.join(directory, "worktree");
|
||||
const outside = path.join(directory, "outside");
|
||||
await mkdir(path.join(root, "src"), { recursive: true });
|
||||
await mkdir(outside, { recursive: true });
|
||||
const scope = await MutationScope.create(root, ["src"]);
|
||||
assert.equal(
|
||||
await scope.assertWritePath("src/new/file.ts"),
|
||||
path.join(root, "src/new/file.ts"),
|
||||
);
|
||||
await scope.mkdir("src/new");
|
||||
await scope.writeFile("src/new/file.ts", "safe\n");
|
||||
assert.equal(await readFile(path.join(root, "src/new/file.ts"), "utf8"), "safe\n");
|
||||
await assert.rejects(scope.assertWritePath("src-other/file.ts"), /outside Assignment ownership/);
|
||||
await assert.rejects(scope.assertWritePath("../outside/file.ts"), /outside Assignment ownership/);
|
||||
await symlink(outside, path.join(root, "src", "linked"));
|
||||
await assert.rejects(
|
||||
scope.assertWritePath("src/linked/file.ts"),
|
||||
/symbolic link|outside the worktree/,
|
||||
);
|
||||
await symlink(outside, path.join(root, "owned-link"));
|
||||
const linkedScope = await MutationScope.create(root, ["owned-link"]);
|
||||
await assert.rejects(
|
||||
linkedScope.assertWritePath("owned-link/file.ts"),
|
||||
/symbolic link|outside the worktree/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("prototype validation requires real candidates and browser evidence, then proves cleanup", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const candidate = path.join(directory, "prototypes", "candidate-1");
|
||||
await mkdir(candidate, { recursive: true });
|
||||
await writeFile(path.join(candidate, "index.html"), "<main>Prototype</main>", "utf8");
|
||||
await writeFile(
|
||||
path.join(candidate, "desktop.png"),
|
||||
grayscalePng(1440, 900, 200),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(candidate, "mobile.png"),
|
||||
grayscalePng(390, 844, 100),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(candidate, "desktop.json"),
|
||||
JSON.stringify({
|
||||
route: "/prototype?variant=1",
|
||||
result: "passed",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
screenshotPath: "prototypes/candidate-1/desktop.png",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(candidate, "mobile.json"),
|
||||
JSON.stringify({
|
||||
route: "/prototype?variant=1",
|
||||
result: "passed",
|
||||
viewport: { width: 390, height: 844 },
|
||||
screenshotPath: "prototypes/candidate-1/mobile.png",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const validator = new FilesystemPrototypeValidator();
|
||||
const validation = await validator.validateCandidate({
|
||||
worktreeRoot: directory,
|
||||
assignment: { ownedPaths: ["prototypes/candidate-1"] },
|
||||
output: {
|
||||
path: "prototypes/candidate-1",
|
||||
route: "/prototype?variant=1",
|
||||
browserEvidence: [
|
||||
"prototypes/candidate-1/mobile.json",
|
||||
"prototypes/candidate-1/desktop.json",
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(validation.artifactFiles.length, 4);
|
||||
assert.match(validation.visualDigest, /^[a-f0-9]{64}$/);
|
||||
await writeFile(
|
||||
path.join(candidate, "fake.png"),
|
||||
Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
Buffer.from("not an image"),
|
||||
]),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(candidate, "fake.json"),
|
||||
JSON.stringify({
|
||||
route: "/prototype?variant=1",
|
||||
result: "passed",
|
||||
viewport: { width: 390, height: 844 },
|
||||
screenshotPath: "prototypes/candidate-1/fake.png",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await assert.rejects(
|
||||
validator.validateCandidate({
|
||||
worktreeRoot: directory,
|
||||
assignment: { ownedPaths: ["prototypes/candidate-1"] },
|
||||
output: {
|
||||
path: "prototypes/candidate-1",
|
||||
route: "/prototype?variant=1",
|
||||
browserEvidence: ["prototypes/candidate-1/fake.json"],
|
||||
},
|
||||
}),
|
||||
/not a decodable PNG/,
|
||||
);
|
||||
await assert.rejects(
|
||||
validator.validateCandidate({
|
||||
worktreeRoot: directory,
|
||||
assignment: { ownedPaths: ["prototypes/candidate-1"] },
|
||||
output: {
|
||||
path: "prototypes/candidate-1",
|
||||
route: "/prototype?variant=1",
|
||||
browserEvidence: ["prototypes/candidate-1/missing.json"],
|
||||
},
|
||||
}),
|
||||
/missing|ENOENT/,
|
||||
);
|
||||
await assert.rejects(
|
||||
validator.validateCleanup({
|
||||
worktreeRoot: directory,
|
||||
candidates: [{ path: "prototypes/candidate-1" }],
|
||||
}),
|
||||
/remains after cleanup/,
|
||||
);
|
||||
await rm(candidate, { recursive: true });
|
||||
await validator.validateCleanup({
|
||||
worktreeRoot: directory,
|
||||
candidates: [{ path: "prototypes/candidate-1" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Git probe and approved host commands enforce identity and ownership", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
await execFileAsync("git", ["init", "-q", directory]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
|
||||
await mkdir(path.join(directory, "src"), { recursive: true });
|
||||
await mkdir(path.join(directory, "other"), { recursive: true });
|
||||
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
|
||||
await writeFile(path.join(directory, "other", "file.ts"), "before\n", "utf8");
|
||||
await writeFile(
|
||||
path.join(directory, "mutate.cjs"),
|
||||
'require("node:fs").writeFileSync(process.argv[2], "after\\n")',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "src", "bytecode_module.py"),
|
||||
"VALUE = 1\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "src", "check_bytecode.py"),
|
||||
"import bytecode_module\nassert bytecode_module.VALUE == 1\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "check-offline.cjs"),
|
||||
'process.exit(process.env.PI_OFFLINE === "1" ? 0 : 1)',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "spawn-hanging-child.cjs"),
|
||||
[
|
||||
'const { spawn } = require("node:child_process");',
|
||||
'const { writeFileSync } = require("node:fs");',
|
||||
'const child = spawn(process.execPath, ["delayed-write.cjs"], { stdio: "ignore" });',
|
||||
'writeFileSync("src/timeout-pids.json", JSON.stringify([process.pid, child.pid]));',
|
||||
"setInterval(() => {}, 1_000);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "delayed-write.cjs"),
|
||||
[
|
||||
'const { writeFileSync } = require("node:fs");',
|
||||
'process.on("SIGTERM", () => {});',
|
||||
'setTimeout(() => writeFileSync("src/delayed.txt", "late\\n"), 1_200);',
|
||||
"setInterval(() => {}, 1_000);",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await execFileAsync("git", ["-C", directory, "add", "."]);
|
||||
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
||||
const probe = new GitStateProbe();
|
||||
const initial = await probe.capture(directory);
|
||||
assert.equal(initial.changedPaths.length, 0);
|
||||
const executor = new ApprovedHostCommandExecutor();
|
||||
const allowed = await executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: process.execPath,
|
||||
args: ["mutate.cjs", "src/file.ts"],
|
||||
});
|
||||
assert.equal(allowed.result.code, 0);
|
||||
assert.deepEqual(allowed.after.changedPaths, ["src/file.ts"]);
|
||||
const bytecodeDisabled = await executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "python3",
|
||||
args: ["src/check_bytecode.py"],
|
||||
});
|
||||
assert.equal(bytecodeDisabled.result.code, 0);
|
||||
await assert.rejects(
|
||||
access(path.join(directory, "src", "__pycache__")),
|
||||
/ENOENT/,
|
||||
);
|
||||
const offline = await executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "env",
|
||||
args: ["PI_OFFLINE=1", process.execPath, "check-offline.cjs"],
|
||||
});
|
||||
assert.equal(offline.result.code, 0);
|
||||
const boundedExecutor = new ApprovedHostCommandExecutor(
|
||||
new ExecFileCommandRunner(),
|
||||
undefined,
|
||||
250,
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
const timedOut = await boundedExecutor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: process.execPath,
|
||||
args: ["spawn-hanging-child.cjs"],
|
||||
});
|
||||
assert.equal(timedOut.result.code, 124);
|
||||
assert.match(timedOut.result.stderr, /timed out after 250ms/);
|
||||
assert.ok(Date.now() - startedAt < 2_500);
|
||||
const timedOutProcessIds = JSON.parse(
|
||||
await readFile(path.join(directory, "src", "timeout-pids.json"), "utf8"),
|
||||
) as number[];
|
||||
for (const processId of timedOutProcessIds) {
|
||||
assert.throws(
|
||||
() => process.kill(processId, 0),
|
||||
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
await assert.rejects(
|
||||
access(path.join(directory, "src", "delayed.txt")),
|
||||
/ENOENT/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "env",
|
||||
args: ["OTHER=value", process.execPath, "check-offline.cjs"],
|
||||
}),
|
||||
/cannot invoke a command wrapper or shell/,
|
||||
);
|
||||
const blockedOfflineCommands: Array<{
|
||||
args: string[];
|
||||
expected: RegExp;
|
||||
}> = [
|
||||
{
|
||||
args: ["PI_OFFLINE=1", "OTHER=value", process.execPath],
|
||||
expected: /cannot invoke a command wrapper or shell/,
|
||||
},
|
||||
{
|
||||
args: ["PI_OFFLINE=1", "sh", "-c", "touch src/bypass"],
|
||||
expected: /cannot invoke a command wrapper or shell/,
|
||||
},
|
||||
{
|
||||
args: ["PI_OFFLINE=1", "git", "clean", "-fd"],
|
||||
expected: /Git host commands are not allowed/,
|
||||
},
|
||||
{
|
||||
args: ["PI_OFFLINE=1", process.execPath, "-e", "process.exit(0)"],
|
||||
expected: /cannot execute inline interpreter code/,
|
||||
},
|
||||
{
|
||||
args: ["PI_OFFLINE=1", "env", "PI_OFFLINE=1", process.execPath],
|
||||
expected: /cannot invoke a command wrapper or shell/,
|
||||
},
|
||||
{
|
||||
args: ["PI_OFFLINE=1", "npm", "exec", "git"],
|
||||
expected: /cannot invoke package-manager executable wrappers/,
|
||||
},
|
||||
];
|
||||
for (const blocked of blockedOfflineCommands) {
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "env",
|
||||
args: blocked.args,
|
||||
}),
|
||||
blocked.expected,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: process.execPath,
|
||||
args: ["mutate.cjs", "other/file.ts"],
|
||||
}),
|
||||
/outside ownership: other\/file.ts/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "git",
|
||||
args: ["reset", "--hard"],
|
||||
}),
|
||||
/Git host commands are not allowed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "git",
|
||||
args: ["-C", ".", "reset", "--hard"],
|
||||
}),
|
||||
/Git host commands are not allowed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "git",
|
||||
args: ["-c", "alias.x=commit", "x", "-m", "bypass"],
|
||||
}),
|
||||
/Git host commands are not allowed/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "env",
|
||||
args: ["git", "clean", "-fd"],
|
||||
}),
|
||||
/cannot invoke a command wrapper or shell/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: process.execPath,
|
||||
args: ["-e", 'require("node:child_process").execFileSync("git")'],
|
||||
}),
|
||||
/cannot execute inline interpreter code/,
|
||||
);
|
||||
await assert.rejects(
|
||||
executor.run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: "sh",
|
||||
args: ["-c", "touch src/shell-created"],
|
||||
}),
|
||||
/cannot invoke a command wrapper or shell/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("command runner reports bounded termination failure as exit 125", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const runner = new ExecFileCommandRunner({
|
||||
async terminateProcessTree(processId) {
|
||||
if (process.platform !== "win32") {
|
||||
process.kill(-processId, "SIGKILL");
|
||||
} else {
|
||||
process.kill(processId, "SIGKILL");
|
||||
}
|
||||
throw new Error("injected termination failure");
|
||||
},
|
||||
});
|
||||
const started = Date.now();
|
||||
const result = await runner.run(
|
||||
directory,
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{ timeoutMs: 20 },
|
||||
);
|
||||
assert.equal(result.code, 125);
|
||||
assert.match(result.stderr, /injected termination failure/);
|
||||
assert.ok(Date.now() - started < 2_000);
|
||||
});
|
||||
});
|
||||
|
||||
test("command runner force-kills a child when the termination helper never settles", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
let childProcessId: number | undefined;
|
||||
const runner = new ExecFileCommandRunner({
|
||||
terminateProcessTree(processId) {
|
||||
childProcessId = processId;
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
});
|
||||
const started = Date.now();
|
||||
const result = await runner.run(
|
||||
directory,
|
||||
process.execPath,
|
||||
["-e", "setInterval(() => {}, 1000)"],
|
||||
{ timeoutMs: 20 },
|
||||
);
|
||||
assert.equal(result.code, 125);
|
||||
assert.match(result.stderr, /termination did not finish within its deadline/);
|
||||
assert.ok(Date.now() - started < 2_000);
|
||||
const terminatedProcessId = childProcessId;
|
||||
assert.ok(terminatedProcessId);
|
||||
assert.throws(
|
||||
() => process.kill(terminatedProcessId, 0),
|
||||
(error: unknown) => (error as NodeJS.ErrnoException).code === "ESRCH",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("Git change detection excludes unchanged pre-existing dirty paths", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
await execFileAsync("git", ["init", "-q", directory]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
|
||||
await mkdir(path.join(directory, "src"), { recursive: true });
|
||||
await writeFile(path.join(directory, "src", "existing.ts"), "base\n", "utf8");
|
||||
await writeFile(path.join(directory, "src", "generated.ts"), "base\n", "utf8");
|
||||
await execFileAsync("git", ["-C", directory, "add", "."]);
|
||||
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
||||
await writeFile(path.join(directory, "src", "existing.ts"), "dirty\n", "utf8");
|
||||
const probe = new GitStateProbe();
|
||||
const before = await probe.capture(directory);
|
||||
await writeFile(path.join(directory, "src", "generated.ts"), "generated\n", "utf8");
|
||||
const after = await probe.capture(directory);
|
||||
assert.deepEqual(probe.changedSince(before, after), ["src/generated.ts"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("post-command Git probe failures are workspace safety failures", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
await execFileAsync("git", ["init", "-q", directory]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.email", "test@example.com"]);
|
||||
await execFileAsync("git", ["-C", directory, "config", "user.name", "Test User"]);
|
||||
await mkdir(path.join(directory, "src"), { recursive: true });
|
||||
await writeFile(path.join(directory, "src", "file.ts"), "before\n", "utf8");
|
||||
await writeFile(
|
||||
path.join(directory, "corrupt.cjs"),
|
||||
'require("node:fs").renameSync(".git", ".git-broken")',
|
||||
"utf8",
|
||||
);
|
||||
await execFileAsync("git", ["-C", directory, "add", "."]);
|
||||
await execFileAsync("git", ["-C", directory, "commit", "-qm", "initial"]);
|
||||
await assert.rejects(
|
||||
new ApprovedHostCommandExecutor().run({
|
||||
worktreeRoot: directory,
|
||||
ownedPaths: ["src"],
|
||||
executable: process.execPath,
|
||||
args: ["corrupt.cjs"],
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof WorkspaceSafetyError &&
|
||||
/Unable to verify Git safety after host command/.test(error.message),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace preparation validates Git state and builds the exact feature branch", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const runner = new FakeRunner(prepareResponses(repository));
|
||||
const manager = new GitWorkspaceManager(runner);
|
||||
const plan = await manager.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "search-dashboard",
|
||||
runId: "run-1",
|
||||
});
|
||||
assert.equal(plan.branch, "feature/search-dashboard");
|
||||
assert.equal(
|
||||
plan.worktreePath,
|
||||
path.join(repository, ".worktrees", "workflow-search-dashboard-run-1"),
|
||||
);
|
||||
assert.equal(plan.baseHead, "abc123");
|
||||
assert.equal(plan.branchExists, false);
|
||||
|
||||
const workspace = await manager.create(plan);
|
||||
assert.equal(workspace.state, "ready");
|
||||
assert.deepEqual(runner.calls.at(-1)?.args, [
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
"feature/search-dashboard",
|
||||
plan.worktreePath,
|
||||
"abc123",
|
||||
]);
|
||||
assert.ok(runner.calls.every((call) => call.executable === "git"));
|
||||
assert.ok(
|
||||
runner.calls.every(
|
||||
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("real Git creation and reconciliation preserve branch and HEAD identity", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const git = (args: string[]) =>
|
||||
execFileAsync("git", args, { cwd: repository, encoding: "utf8" });
|
||||
await git(["init", "-b", "main"]);
|
||||
await git(["config", "user.email", "workflow@example.invalid"]);
|
||||
await git(["config", "user.name", "Pi Workflow Test"]);
|
||||
await writeFile(
|
||||
path.join(repository, ".gitignore"),
|
||||
".worktrees/\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(repository, "README.md"), "# Fixture\n", "utf8");
|
||||
await git(["add", ".gitignore", "README.md"]);
|
||||
await git(["commit", "-m", "initial fixture"]);
|
||||
|
||||
const manager = new GitWorkspaceManager();
|
||||
const plan = await manager.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "real-feature",
|
||||
runId: "run-1",
|
||||
});
|
||||
const workspace = await manager.create(plan);
|
||||
assert.equal(workspace.state, "ready");
|
||||
assert.equal(
|
||||
(
|
||||
await manager.reconcile({
|
||||
...workspace,
|
||||
state: "creating",
|
||||
})
|
||||
).state,
|
||||
"ready",
|
||||
);
|
||||
const branch = await execFileAsync("git", ["branch", "--show-current"], {
|
||||
cwd: plan.worktreePath,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(branch.stdout.trim(), "feature/real-feature");
|
||||
});
|
||||
});
|
||||
|
||||
test("dirty sources require approval with bounded evidence while conflicts remain blocked", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const dirty = new GitWorkspaceManager(
|
||||
new FakeRunner(prepareResponses(repository, { dirty: true })),
|
||||
);
|
||||
await assert.rejects(
|
||||
dirty.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(error instanceof WorkspaceValidationError);
|
||||
assert.equal(error.code, "dirty-source");
|
||||
assert.deepEqual(error.sourceChanges, [
|
||||
{ path: "src/file.ts", status: " M" },
|
||||
{ path: "src/new-file.ts", status: "??" },
|
||||
]);
|
||||
assert.equal(error.sourceChangesOmitted, 0);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const allowed = await dirty.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
allowDirtySource: true,
|
||||
});
|
||||
assert.equal(allowed.baseHead, "abc123");
|
||||
assert.equal(allowed.dirtySourceAllowed, true);
|
||||
assert.equal(allowed.sourceChanges.length, 2);
|
||||
|
||||
const unmerged = new GitWorkspaceManager(
|
||||
new FakeRunner(prepareResponses(repository, { unmerged: true })),
|
||||
);
|
||||
await assert.rejects(
|
||||
unmerged.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
allowDirtySource: true,
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof WorkspaceValidationError &&
|
||||
error.code === "unmerged-source",
|
||||
);
|
||||
|
||||
const unignored = new GitWorkspaceManager(
|
||||
new FakeRunner(prepareResponses(repository, { ignored: false })),
|
||||
);
|
||||
await assert.rejects(
|
||||
unignored.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof WorkspaceValidationError &&
|
||||
error.code === "unignored-root",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace preparation rejects a symlinked worktree root", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const outside = await mkdtemp(path.join(tmpdir(), "workflow-outside-"));
|
||||
try {
|
||||
await symlink(outside, path.join(repository, ".worktrees"));
|
||||
const manager = new GitWorkspaceManager(
|
||||
new FakeRunner(prepareResponses(repository)),
|
||||
);
|
||||
await assert.rejects(
|
||||
manager.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof WorkspaceValidationError &&
|
||||
error.code === "unignored-root" &&
|
||||
/symlink/.test(error.message),
|
||||
);
|
||||
} finally {
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("workspace preparation reports branch collisions and existing paths", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const manager = new GitWorkspaceManager(
|
||||
new FakeRunner(prepareResponses(repository, { branchExists: true })),
|
||||
);
|
||||
const plan = await manager.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
});
|
||||
assert.equal(plan.branchExists, true);
|
||||
|
||||
await mkdir(plan.worktreePath, { recursive: true });
|
||||
await assert.rejects(
|
||||
manager.prepare({
|
||||
sourceRoot: repository,
|
||||
configuredRoot: ".worktrees",
|
||||
featureName: "feature",
|
||||
runId: "run-1",
|
||||
}),
|
||||
(error) =>
|
||||
error instanceof WorkspaceValidationError &&
|
||||
error.code === "existing-worktree-path",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("interrupted workspace creation reconciles only an exact path and branch", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const root = path.join(repository, ".worktrees", "run-1");
|
||||
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
|
||||
const manager = new GitWorkspaceManager(
|
||||
new FakeRunner((args) =>
|
||||
args.join(" ") === "worktree list --porcelain"
|
||||
? result(0, listed)
|
||||
: result(1, "", "unexpected"),
|
||||
),
|
||||
);
|
||||
const workspace = {
|
||||
state: "creating" as const,
|
||||
sourceRoot: repository,
|
||||
root,
|
||||
branch: "feature/feature",
|
||||
baseHead: "abc123",
|
||||
};
|
||||
assert.equal((await manager.reconcile(workspace)).state, "ready");
|
||||
assert.equal(
|
||||
(
|
||||
await manager.reconcile({
|
||||
...workspace,
|
||||
branch: "feature/other",
|
||||
})
|
||||
).state,
|
||||
"failed",
|
||||
);
|
||||
assert.equal(
|
||||
(await manager.reconcile({ ...workspace, baseHead: "different" })).state,
|
||||
"failed",
|
||||
);
|
||||
assert.equal(
|
||||
(await manager.reconcile(workspace, path.join(repository, "other")))
|
||||
.state,
|
||||
"failed",
|
||||
);
|
||||
|
||||
const absent = new GitWorkspaceManager(new FakeRunner(() => result(0, "")));
|
||||
assert.equal((await absent.reconcile(workspace)).state, "creating");
|
||||
});
|
||||
});
|
||||
|
||||
test("uncertain worktree-add results are reconciled before returning", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const root = path.join(repository, ".worktrees", "run-1");
|
||||
const plan = {
|
||||
sourceRoot: repository,
|
||||
worktreeRoot: path.dirname(root),
|
||||
worktreePath: root,
|
||||
branch: "feature/feature",
|
||||
baseHead: "abc123",
|
||||
branchExists: false,
|
||||
sourceChanges: [],
|
||||
sourceChangesOmitted: 0,
|
||||
dirtySourceAllowed: false,
|
||||
};
|
||||
const listed = `worktree ${root}\nHEAD abc123\nbranch refs/heads/feature/feature\n\n`;
|
||||
const partial = new GitWorkspaceManager(
|
||||
new FakeRunner((args) => {
|
||||
if (args[0] === "show-ref") return result(1);
|
||||
return args[0] === "worktree" && args[1] === "add"
|
||||
? result(128, "", "interrupted")
|
||||
: result(0, listed);
|
||||
}),
|
||||
);
|
||||
assert.equal((await partial.create(plan)).state, "ready");
|
||||
|
||||
const absent = new GitWorkspaceManager(
|
||||
new FakeRunner((args) => {
|
||||
if (args[0] === "show-ref") return result(1);
|
||||
return args[0] === "worktree" && args[1] === "add"
|
||||
? result(128, "", "interrupted")
|
||||
: result(0, "");
|
||||
}),
|
||||
);
|
||||
const uncertain = await absent.create(plan);
|
||||
assert.equal(uncertain.state, "creating");
|
||||
assert.match(uncertain.error ?? "", /returned 128/);
|
||||
});
|
||||
});
|
||||
|
||||
test("retry adopts an exact orphan branch left by interrupted creation", async () => {
|
||||
await withTempDir(async (repository) => {
|
||||
const root = path.join(repository, ".worktrees", "run-1");
|
||||
const runner = new FakeRunner((args) => {
|
||||
if (args[0] === "show-ref") return result(0);
|
||||
if (args[0] === "rev-parse") return result(0, "abc123\n");
|
||||
if (args[0] === "worktree" && args[1] === "add") return result(0);
|
||||
return result(1, "", "unexpected");
|
||||
});
|
||||
const manager = new GitWorkspaceManager(runner);
|
||||
const plan = {
|
||||
sourceRoot: repository,
|
||||
worktreeRoot: path.dirname(root),
|
||||
worktreePath: root,
|
||||
branch: "feature/feature",
|
||||
baseHead: "abc123",
|
||||
branchExists: false,
|
||||
sourceChanges: [],
|
||||
sourceChangesOmitted: 0,
|
||||
dirtySourceAllowed: false,
|
||||
};
|
||||
assert.equal((await manager.create(plan)).state, "ready");
|
||||
assert.deepEqual(runner.calls.at(-1)?.args, [
|
||||
"worktree",
|
||||
"add",
|
||||
root,
|
||||
"feature/feature",
|
||||
]);
|
||||
|
||||
const mismatch = new GitWorkspaceManager(
|
||||
new FakeRunner((args) =>
|
||||
args[0] === "show-ref" ? result(0) : result(0, "different\n"),
|
||||
),
|
||||
);
|
||||
assert.equal((await mismatch.create(plan)).state, "failed");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user