Files
harness-tools/extensions/pi-workflow/workspace.ts
T

1246 lines
40 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import {
lstat,
mkdir,
open,
readFile,
readlink,
realpath,
rm,
} from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";
import { inflateSync } from "node:zlib";
import type { WorkflowWorkspace } from "./domain.ts";
const execFileAsync = promisify(execFile);
export interface CommandResult {
code: number;
stdout: string;
stderr: string;
}
export interface CommandRunOptions {
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
}
export interface CommandRunner {
run(
cwd: string,
executable: string,
args: string[],
options?: CommandRunOptions,
): Promise<CommandResult>;
}
export class WorkspaceSafetyError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkspaceSafetyError";
}
}
export interface GitStateSnapshot {
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>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function pngCrc32(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 paethPredictor(left: number, above: number, upperLeft: number) {
const estimate = left + above - upperLeft;
const leftDistance = Math.abs(estimate - left);
const aboveDistance = Math.abs(estimate - above);
const upperLeftDistance = Math.abs(estimate - upperLeft);
if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left;
return aboveDistance <= upperLeftDistance ? above : upperLeft;
}
function parsePngVisual(contents: Buffer) {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (
contents.byteLength < signature.byteLength ||
!contents.subarray(0, signature.byteLength).equals(signature)
) {
throw new Error("invalid PNG signature");
}
let offset = signature.byteLength;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = -1;
let palette = Buffer.alloc(0);
const compressed: Buffer[] = [];
let sawHeader = false;
let sawEnd = false;
while (offset < contents.byteLength) {
if (offset + 12 > contents.byteLength) throw new Error("truncated PNG chunk");
const length = contents.readUInt32BE(offset);
const end = offset + 12 + length;
if (end > contents.byteLength) throw new Error("truncated PNG data");
const type = contents.subarray(offset + 4, offset + 8);
const data = contents.subarray(offset + 8, offset + 8 + length);
const expectedCrc = contents.readUInt32BE(offset + 8 + length);
if (pngCrc32(Buffer.concat([type, data])) !== expectedCrc) {
throw new Error("invalid PNG checksum");
}
const name = type.toString("ascii");
if (name === "IHDR") {
if (sawHeader || length !== 13 || offset !== signature.byteLength) {
throw new Error("invalid PNG header");
}
sawHeader = true;
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8] ?? 0;
colorType = data[9] ?? -1;
if (
width < 1 ||
height < 1 ||
width > 16_384 ||
height > 16_384 ||
data[10] !== 0 ||
data[11] !== 0 ||
data[12] !== 0
) {
throw new Error("unsupported PNG header");
}
} else if (name === "IDAT") {
compressed.push(Buffer.from(data));
} else if (name === "PLTE" || name === "tRNS") {
palette = Buffer.concat([palette, Buffer.from(type), Buffer.from(data)]);
} else if (name === "IEND") {
if (length !== 0) throw new Error("invalid PNG end chunk");
sawEnd = true;
offset = end;
break;
}
offset = end;
}
if (!sawHeader || !sawEnd || compressed.length === 0 || offset !== contents.byteLength) {
throw new Error("incomplete PNG image");
}
const channels = new Map([
[0, 1],
[2, 3],
[3, 1],
[4, 2],
[6, 4],
]).get(colorType);
if (!channels || ![1, 2, 4, 8, 16].includes(bitDepth)) {
throw new Error("unsupported PNG color format");
}
const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
const bytesPerPixel = Math.max(1, Math.ceil((channels * bitDepth) / 8));
const filtered = inflateSync(Buffer.concat(compressed), {
maxOutputLength: (rowBytes + 1) * height,
});
if (filtered.byteLength !== (rowBytes + 1) * height) {
throw new Error("invalid PNG scanline length");
}
const pixels = Buffer.alloc(rowBytes * height);
for (let row = 0; row < height; row++) {
const sourceOffset = row * (rowBytes + 1);
const targetOffset = row * rowBytes;
const filter = filtered[sourceOffset];
if (filter === undefined || filter > 4) throw new Error("invalid PNG filter");
for (let column = 0; column < rowBytes; column++) {
const encoded = filtered[sourceOffset + 1 + column] ?? 0;
const left = column >= bytesPerPixel ? (pixels[targetOffset + column - bytesPerPixel] ?? 0) : 0;
const above = row > 0 ? (pixels[targetOffset - rowBytes + column] ?? 0) : 0;
const upperLeft =
row > 0 && column >= bytesPerPixel
? (pixels[targetOffset - rowBytes + column - bytesPerPixel] ?? 0)
: 0;
const predictor =
filter === 0
? 0
: filter === 1
? left
: filter === 2
? above
: filter === 3
? Math.floor((left + above) / 2)
: paethPredictor(left, above, upperLeft);
pixels[targetOffset + column] = (encoded + predictor) & 0xff;
}
}
return {
width,
height,
digest: createHash("sha256")
.update(`${width}:${height}:${bitDepth}:${colorType}\n`)
.update(palette)
.update(pixels)
.digest("hex"),
};
}
function isWithin(root: string, candidate: string) {
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
}
async function nearestExisting(candidate: string, floor: string) {
let current = candidate;
while (isWithin(floor, current)) {
try {
await lstat(current);
return current;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (current === floor) break;
current = path.dirname(current);
}
throw new Error(`No existing ancestor for ${candidate} is inside ${floor}`);
}
export class MutationScope {
readonly root: string;
readonly ownedRoots: string[];
private constructor(root: string, ownedRoots: string[]) {
this.root = root;
this.ownedRoots = ownedRoots;
}
static async create(worktreeRoot: string, ownedPaths: string[]) {
if (ownedPaths.length === 0) throw new Error("Mutation ownership cannot be empty");
const root = await realpath(path.resolve(worktreeRoot));
const ownedRoots = ownedPaths.map((ownedPath) => {
if (!ownedPath || path.isAbsolute(ownedPath)) {
throw new Error(`Owned path must be relative: ${ownedPath}`);
}
const resolved = path.resolve(root, ownedPath);
if (!isWithin(root, resolved)) {
throw new Error(`Owned path escapes the worktree: ${ownedPath}`);
}
return resolved;
});
return new MutationScope(root, [...new Set(ownedRoots)]);
}
async remove(targetPath: string) {
const target = await this.assertWritePath(targetPath);
await rm(target, { recursive: true, force: true });
}
owns(candidate: string) {
const absolute = path.isAbsolute(candidate)
? path.resolve(candidate)
: path.resolve(this.root, candidate);
return (
isWithin(this.root, absolute) &&
this.ownedRoots.some((ownedRoot) => isWithin(ownedRoot, absolute))
);
}
async assertWritePath(candidate: string) {
const absolute = path.isAbsolute(candidate)
? path.resolve(candidate)
: path.resolve(this.root, candidate);
if (!this.owns(absolute)) {
throw new Error(`Write path is outside Assignment ownership: ${candidate}`);
}
const ancestor = await nearestExisting(absolute, this.root);
const canonicalAncestor = await realpath(ancestor);
if (!isWithin(this.root, canonicalAncestor)) {
throw new Error(`Write path resolves outside the worktree: ${candidate}`);
}
let current = this.root;
const relative = path.relative(this.root, absolute);
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
try {
const details = await lstat(current);
if (details.isSymbolicLink()) {
throw new Error(`Write path traverses a symbolic link: ${candidate}`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
throw error;
}
}
return absolute;
}
async writeFile(targetPath: string, content: string) {
const target = await this.assertWritePath(targetPath);
const handle = await open(
target,
constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW,
0o666,
);
try {
const descriptorPath = await realpath(`/proc/self/fd/${handle.fd}`).catch(
() => realpath(target),
);
if (!this.owns(descriptorPath)) {
throw new WorkspaceSafetyError(
`Opened write target escapes worktree ownership: ${targetPath}`,
);
}
await handle.truncate(0);
await handle.writeFile(content, "utf8");
} finally {
await handle.close();
}
}
async mkdir(targetPath: string) {
const target = await this.assertWritePath(targetPath);
const relative = path.relative(this.root, target);
let current = this.root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
await mkdir(current).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
const canonical = await realpath(current);
if (this.owns(current) && !this.owns(canonical)) {
throw new WorkspaceSafetyError(
`Created directory escapes worktree ownership: ${targetPath}`,
);
}
}
}
}
async function pathDigest(root: string, relativePath: string) {
const target = path.resolve(root, relativePath);
try {
const details = await lstat(target);
if (details.isSymbolicLink()) {
return `symlink:${await readlink(target)}`;
}
if (details.isDirectory()) return "directory";
const content = await readFile(target);
return `file:${createHash("sha256").update(content).digest("hex")}`;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
throw error;
}
}
function changedPathStatuses(status: string) {
const entries = status.split("\0");
const statuses: Record<string, string> = {};
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
if (!entry) continue;
const code = entry.slice(0, 2);
const file = entry.slice(3);
if (file) statuses[file] = code;
if (code.includes("R") || code.includes("C")) {
const renamed = entries[++index];
if (renamed) statuses[renamed] = code;
}
}
return statuses;
}
export class GitStateProbe {
private readonly runner: CommandRunner;
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
this.runner = runner;
}
async captureRepository(worktreeRoot: string) {
const root = await realpath(path.resolve(worktreeRoot));
const listing = await this.git(root, ["worktree", "list", "--porcelain"]);
const roots = listing
.split("\n")
.filter((line) => line.startsWith("worktree "))
.map((line) => line.slice("worktree ".length).trim());
const snapshots = new Map<string, GitStateSnapshot>();
for (const candidate of roots) {
try {
const snapshot = await this.capture(candidate);
snapshots.set(snapshot.root, snapshot);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
return snapshots;
}
async capture(worktreeRoot: string): Promise<GitStateSnapshot> {
const root = await realpath(path.resolve(worktreeRoot));
const [branch, head, common, gitDirectory, worktreeList, status] = await Promise.all([
this.git(root, ["branch", "--show-current"]),
this.git(root, ["rev-parse", "HEAD"]),
this.git(root, ["rev-parse", "--git-common-dir"]),
this.git(root, ["rev-parse", "--git-dir"]),
this.git(root, ["worktree", "list", "--porcelain"]),
this.git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
]);
const commonDirectory = path.resolve(root, common.trim());
const resolvedGitDirectory = path.resolve(root, gitDirectory.trim());
const metadataHash = createHash("sha256");
for (const metadataPath of [
path.join(commonDirectory, "config"),
path.join(resolvedGitDirectory, "gitdir"),
path.join(resolvedGitDirectory, "commondir"),
path.join(resolvedGitDirectory, "HEAD"),
path.join(resolvedGitDirectory, "locked"),
path.join(resolvedGitDirectory, "config.worktree"),
]) {
metadataHash.update(metadataPath);
try {
metadataHash.update(await readFile(metadataPath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
metadataHash.update("missing");
}
}
const pathStatuses = changedPathStatuses(status);
const paths = Object.keys(pathStatuses).sort();
const pathDigests = Object.fromEntries(
await Promise.all(
paths.map(async (changed) => [changed, await pathDigest(root, changed)] as const),
),
);
return {
root,
branch: branch.trim(),
head: head.trim(),
commonDirectory,
gitDirectory: resolvedGitDirectory,
worktreeListDigest: createHash("sha256")
.update(worktreeList)
.digest("hex"),
worktreeMetadataDigest: metadataHash.digest("hex"),
status,
changedPaths: paths,
pathStatuses,
pathDigests,
};
}
changedSince(before: GitStateSnapshot, after: GitStateSnapshot) {
const paths = [...new Set([...before.changedPaths, ...after.changedPaths])];
return paths.filter(
(changed) =>
before.pathStatuses[changed] !== after.pathStatuses[changed] ||
before.pathDigests[changed] !== after.pathDigests[changed],
);
}
assertIdentity(before: GitStateSnapshot, after: GitStateSnapshot) {
if (
before.root !== after.root ||
before.branch !== after.branch ||
before.head !== after.head ||
before.commonDirectory !== after.commonDirectory ||
before.gitDirectory !== after.gitDirectory ||
before.worktreeListDigest !== after.worktreeListDigest ||
before.worktreeMetadataDigest !== after.worktreeMetadataDigest
) {
throw new WorkspaceSafetyError(
"Git branch, HEAD, or worktree identity changed during mutation",
);
}
}
private async git(cwd: string, args: string[]) {
const result = await this.runner.run(cwd, "git", args);
if (result.code !== 0) {
throw new Error(`Git state probe failed: ${result.stderr.trim()}`);
}
return result.stdout;
}
}
const SHELL_EXECUTABLES = new Set([
"bash",
"cmd",
"cmd.exe",
"fish",
"powershell",
"pwsh",
"sh",
"zsh",
]);
const COMMAND_WRAPPERS = new Set(["env", "find", "npx", "xargs"]);
const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
const INLINE_EVALUATION_FLAGS = new Map<string, Set<string>>([
["node", new Set(["-e", "--eval", "-p", "--print"])],
["python", new Set(["-c"])],
["python3", new Set(["-c"])],
["ruby", new Set(["-e"])],
["perl", new Set(["-e", "-E"])],
]);
const PACKAGE_EXEC_SUBCOMMANDS = new Map<string, Set<string>>([
["npm", new Set(["exec", "x"])],
["pnpm", new Set(["dlx", "exec"])],
["yarn", new Set(["dlx", "exec"])],
["bun", new Set(["x"])],
]);
const DEFAULT_HOST_COMMAND_TIMEOUT_MS = 3 * 60 * 1_000;
export class ApprovedHostCommandExecutor {
private readonly runner: CommandRunner;
private readonly probe: GitStateProbe;
private readonly timeoutMs: number;
constructor(
runner: CommandRunner = new ExecFileCommandRunner(),
probe: GitStateProbe = new GitStateProbe(runner),
timeoutMs = DEFAULT_HOST_COMMAND_TIMEOUT_MS,
) {
this.runner = runner;
this.probe = probe;
this.timeoutMs = timeoutMs;
}
async run(options: {
worktreeRoot: string;
ownedPaths: string[];
executable: string;
args: string[];
}) {
if (!options.executable || options.args.some((argument) => argument.includes("\0"))) {
throw new Error("Host commands require a fixed executable and argument array");
}
let executable = options.executable;
let args = [...options.args];
const commandEnv: NodeJS.ProcessEnv = {
...process.env,
PYTHONDONTWRITEBYTECODE: "1",
};
if (
path.basename(executable).toLowerCase() === "env" &&
args[0] === "PI_OFFLINE=1" &&
args[1] &&
!ENVIRONMENT_ASSIGNMENT.test(args[1])
) {
commandEnv.PI_OFFLINE = "1";
executable = args[1];
args = args.slice(2);
}
const executableName = path.basename(executable).toLowerCase();
if (
SHELL_EXECUTABLES.has(executableName) ||
COMMAND_WRAPPERS.has(executableName)
) {
throw new Error("Host commands cannot invoke a command wrapper or shell");
}
const evaluationFlags = INLINE_EVALUATION_FLAGS.get(executableName);
if (evaluationFlags && args.some((argument) => evaluationFlags.has(argument))) {
throw new Error("Host commands cannot execute inline interpreter code");
}
const packageExecCommands = PACKAGE_EXEC_SUBCOMMANDS.get(executableName);
if (packageExecCommands?.has(args[0] ?? "")) {
throw new Error("Host commands cannot invoke package-manager executable wrappers");
}
if (executableName === "git") {
throw new Error(
"Git host commands are not allowed; workspace Git operations are managed internally",
);
}
const root = await realpath(path.resolve(options.worktreeRoot));
const scope =
options.ownedPaths.length > 0
? await MutationScope.create(root, options.ownedPaths)
: undefined;
const beforeRepository = await this.probe.captureRepository(root);
const before = beforeRepository.get(root) ?? (await this.probe.capture(root));
const result = await this.runner.run(root, executable, args, {
env: commandEnv,
timeoutMs: this.timeoutMs,
});
try {
const afterRepository = await this.probe.captureRepository(root);
const after = afterRepository.get(root) ?? (await this.probe.capture(root));
this.probe.assertIdentity(before, after);
for (const [repositoryRoot, repositoryBefore] of beforeRepository) {
if (repositoryRoot === root) continue;
const repositoryAfter = afterRepository.get(repositoryRoot);
if (!repositoryAfter) {
throw new WorkspaceSafetyError(
`Host command removed repository worktree ${repositoryRoot}`,
);
}
this.probe.assertIdentity(repositoryBefore, repositoryAfter);
const touched = this.probe.changedSince(repositoryBefore, repositoryAfter);
if (touched.length > 0) {
throw new WorkspaceSafetyError(
`Host command changed another repository worktree: ${repositoryRoot}`,
);
}
}
const touchedPaths = this.probe.changedSince(before, after);
const outOfScope = scope
? touchedPaths.filter((changed) => !scope.owns(changed))
: touchedPaths;
if (outOfScope.length > 0) {
throw new WorkspaceSafetyError(
`Host command changed paths outside ownership: ${outOfScope.join(", ")}`,
);
}
return { result, before, after };
} catch (error) {
if (error instanceof WorkspaceSafetyError) throw error;
throw new WorkspaceSafetyError(
`Unable to verify Git safety after host command: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
export class FilesystemPrototypeValidator {
async validateCandidate(options: {
worktreeRoot: string;
assignment: { ownedPaths: string[] };
output: { path: string; route: string; browserEvidence: string[] };
}) {
const scope = await MutationScope.create(
options.worktreeRoot,
options.assignment.ownedPaths,
);
const candidatePath = await scope.assertWritePath(options.output.path);
const candidate = await lstat(candidatePath);
if (candidate.isSymbolicLink() || (!candidate.isDirectory() && !candidate.isFile())) {
throw new WorkspaceSafetyError("Prototype candidate path is not a real file or directory");
}
const widths: number[] = [];
const artifactFiles: string[] = [];
const screenshotDigests: string[] = [];
for (const evidencePath of options.output.browserEvidence) {
const absoluteEvidence = await scope.assertWritePath(evidencePath);
const evidence = await lstat(absoluteEvidence);
if (
evidence.isSymbolicLink() ||
!evidence.isFile() ||
evidence.size === 0 ||
evidence.size > 64 * 1024
) {
throw new WorkspaceSafetyError(
`Prototype browser evidence is missing or invalid: ${evidencePath}`,
);
}
let report: unknown;
try {
report = JSON.parse(await readFile(absoluteEvidence, "utf8"));
} catch {
throw new WorkspaceSafetyError(
`Prototype browser evidence must be a JSON validation report: ${evidencePath}`,
);
}
if (
!isRecord(report) ||
report.route !== options.output.route ||
report.result !== "passed" ||
!isRecord(report.viewport) ||
!Number.isSafeInteger(report.viewport.width) ||
!Number.isSafeInteger(report.viewport.height) ||
typeof report.screenshotPath !== "string"
) {
throw new WorkspaceSafetyError(
`Prototype browser evidence report is invalid: ${evidencePath}`,
);
}
const screenshotPath = await scope.assertWritePath(report.screenshotPath);
const screenshot = await lstat(screenshotPath);
if (
screenshot.isSymbolicLink() ||
!screenshot.isFile() ||
screenshot.size === 0
) {
throw new WorkspaceSafetyError(
`Prototype browser screenshot is invalid: ${report.screenshotPath}`,
);
}
const screenshotContents = await readFile(screenshotPath);
let visual: ReturnType<typeof parsePngVisual>;
try {
visual = parsePngVisual(screenshotContents);
} catch (error) {
throw new WorkspaceSafetyError(
`Prototype browser screenshot is not a decodable PNG: ${report.screenshotPath} (${error instanceof Error ? error.message : String(error)})`,
);
}
if (
visual.width !== report.viewport.width ||
visual.height !== report.viewport.height
) {
throw new WorkspaceSafetyError(
`Prototype screenshot dimensions do not match its viewport: ${report.screenshotPath}`,
);
}
artifactFiles.push(evidencePath, report.screenshotPath);
screenshotDigests.push(visual.digest);
widths.push(report.viewport.width);
}
if (!widths.some((width) => width <= 480) || !widths.some((width) => width >= 1024)) {
throw new WorkspaceSafetyError(
"Prototype browser evidence must include passed mobile and desktop viewports",
);
}
return {
artifactFiles: [...new Set(artifactFiles)],
visualDigest: createHash("sha256")
.update(screenshotDigests.sort().join("\n"))
.digest("hex"),
};
}
async validateCleanup(options: {
worktreeRoot: string;
candidates: Array<{ path: string }>;
}) {
const root = await realpath(path.resolve(options.worktreeRoot));
for (const candidate of options.candidates) {
const target = path.resolve(root, candidate.path);
if (!isWithin(root, target)) {
throw new WorkspaceSafetyError("Prototype cleanup candidate escapes worktree");
}
try {
await lstat(target);
throw new WorkspaceSafetyError(
`Prototype-only path remains after cleanup: ${candidate.path}`,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
}
export class GitDiffProvider {
private readonly runner: CommandRunner;
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
this.runner = runner;
}
async read(worktreeRoot: string, baseHead = "HEAD") {
const root = await realpath(path.resolve(worktreeRoot));
const diff = await this.runner.run(root, "git", [
"diff",
"--binary",
"--no-ext-diff",
baseHead,
"--",
]);
if (diff.code !== 0) {
throw new Error(`Unable to read review diff: ${diff.stderr.trim()}`);
}
const untracked = await this.runner.run(root, "git", [
"ls-files",
"--others",
"--exclude-standard",
"-z",
]);
if (untracked.code !== 0) {
throw new Error(`Unable to list untracked review files: ${untracked.stderr.trim()}`);
}
const additions: string[] = [];
for (const relative of untracked.stdout.split("\0").filter(Boolean)) {
const target = path.resolve(root, relative);
if (!isWithin(root, target)) {
throw new WorkspaceSafetyError(`Untracked path escapes worktree: ${relative}`);
}
const details = await lstat(target);
if (details.isSymbolicLink()) {
additions.push(`## Untracked symlink ${relative}\n${await readlink(target)}`);
} else if (details.isFile()) {
additions.push(
`## Untracked file ${relative}\n${(await readFile(target)).toString("utf8")}`,
);
}
}
const completeDiff = `${diff.stdout}\n${additions.join("\n\n")}`;
if (Buffer.byteLength(completeDiff, "utf8") > 4 * 1024 * 1024) {
throw new WorkspaceSafetyError(
"Review diff exceeds the 4 MiB audited limit and cannot be reviewed completely",
);
}
return completeDiff;
}
}
export interface WorkspacePlan {
sourceRoot: string;
worktreeRoot: string;
worktreePath: string;
branch: string;
baseHead: string;
branchExists: boolean;
}
export interface PrepareWorkspaceOptions {
sourceRoot: string;
configuredRoot: string;
featureName: string;
runId: string;
}
export class WorkspaceValidationError extends Error {
readonly code:
| "not-repository"
| "dirty-source"
| "unignored-root"
| "existing-worktree-path"
| "git-failed";
constructor(code: WorkspaceValidationError["code"], message: string) {
super(message);
this.name = "WorkspaceValidationError";
this.code = code;
}
}
const PROCESS_TERMINATION_GRACE_MS = 100;
const PROCESS_TERMINATION_POLL_MS = 10;
const PROCESS_TERMINATION_TIMEOUT_MS = 1_000;
function wait(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
function processGroupExists(processId: number) {
try {
process.kill(-processId, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
}
function signalProcessGroup(processId: number, signal: NodeJS.Signals) {
try {
process.kill(-processId, signal);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
}
}
async function terminateProcessTree(processId: number) {
if (process.platform === "win32") {
await execFileAsync("taskkill", ["/pid", String(processId), "/T", "/F"]);
return;
}
signalProcessGroup(processId, "SIGTERM");
await wait(PROCESS_TERMINATION_GRACE_MS);
signalProcessGroup(processId, "SIGKILL");
const deadline = Date.now() + PROCESS_TERMINATION_TIMEOUT_MS;
while (processGroupExists(processId)) {
if (Date.now() >= deadline) {
throw new Error(`process group ${processId} remained alive after SIGKILL`);
}
await wait(PROCESS_TERMINATION_POLL_MS);
}
}
const COMMAND_OUTPUT_LIMIT_BYTES = 4 * 1024 * 1024;
export class ExecFileCommandRunner implements CommandRunner {
async run(
cwd: string,
executable: string,
args: string[],
options: CommandRunOptions = {},
) {
return new Promise<CommandResult>((resolve) => {
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let spawnError: Error | undefined;
let timedOut = false;
let outputExceeded = false;
let termination: Promise<void> | undefined;
let timeout: NodeJS.Timeout | undefined;
const child = spawn(executable, args, {
cwd,
detached: process.platform !== "win32",
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
const terminate = () => {
if (!termination && child.pid !== undefined) {
termination = terminateProcessTree(child.pid);
}
};
const capture = (chunks: Buffer[], chunk: Buffer, stream: "stdout" | "stderr") => {
const capturedBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
const remaining = COMMAND_OUTPUT_LIMIT_BYTES - capturedBytes;
if (remaining > 0) chunks.push(chunk.subarray(0, remaining));
if (stream === "stdout") stdoutBytes += chunk.length;
else stderrBytes += chunk.length;
if (chunk.length > remaining && !outputExceeded) {
outputExceeded = true;
terminate();
}
};
child.stdout.on("data", (chunk: Buffer) => capture(stdoutChunks, chunk, "stdout"));
child.stderr.on("data", (chunk: Buffer) => capture(stderrChunks, chunk, "stderr"));
child.on("error", (error) => {
spawnError = error;
});
child.on("close", async (code) => {
if (timeout !== undefined) clearTimeout(timeout);
let terminationError: string | undefined;
try {
await termination;
} catch (failure) {
terminationError = failure instanceof Error ? failure.message : String(failure);
}
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
const stderr = Buffer.concat(stderrChunks).toString("utf8");
if (timedOut) {
const detail = [stderr, terminationError].filter(Boolean).join("\n");
resolve({
code: terminationError ? 125 : 124,
stdout,
stderr: `Host command timed out after ${options.timeoutMs}ms${detail ? `\n${detail}` : ""}`,
});
return;
}
if (outputExceeded) {
resolve({
code: terminationError ? 125 : 1,
stdout,
stderr: `Host command output exceeded ${COMMAND_OUTPUT_LIMIT_BYTES} bytes${terminationError ? `\n${terminationError}` : ""}`,
});
return;
}
resolve({
code: code ?? 1,
stdout,
stderr: stderr || spawnError?.message || "",
});
});
if (options.timeoutMs !== undefined) {
timeout = setTimeout(() => {
timedOut = true;
terminate();
}, options.timeoutMs);
}
});
}
}
export class GitWorkspaceManager {
private readonly runner: CommandRunner;
constructor(runner: CommandRunner = new ExecFileCommandRunner()) {
this.runner = runner;
}
async prepare(options: PrepareWorkspaceOptions): Promise<WorkspacePlan> {
const requestedRoot = path.resolve(options.sourceRoot);
const repository = await this.git(requestedRoot, [
"rev-parse",
"--show-toplevel",
]);
if (repository.code !== 0) {
throw new WorkspaceValidationError(
"not-repository",
`Unable to resolve Git repository: ${repository.stderr.trim()}`,
);
}
const sourceRoot = await realpath(path.resolve(repository.stdout.trim()));
if (sourceRoot !== (await realpath(requestedRoot))) {
throw new WorkspaceValidationError(
"not-repository",
`Workflow source must be the repository root ${sourceRoot}`,
);
}
const status = await this.git(sourceRoot, [
"status",
"--porcelain",
"--untracked-files=normal",
]);
if (status.code !== 0) {
throw new WorkspaceValidationError(
"git-failed",
`Unable to inspect source state: ${status.stderr.trim()}`,
);
}
if (status.stdout.trim().length > 0) {
throw new WorkspaceValidationError(
"dirty-source",
"Source checkout must be clean before creating a workflow worktree",
);
}
const worktreeRoot = await this.containedWorktreeRoot(
sourceRoot,
options.configuredRoot,
);
const relativeRoot = path
.relative(sourceRoot, worktreeRoot)
.replaceAll("\\", "/");
const ignored = await this.git(sourceRoot, [
"check-ignore",
"--quiet",
"--no-index",
"--",
`${relativeRoot}/.pi-workflow-ignore-check`,
]);
if (ignored.code !== 0) {
throw new WorkspaceValidationError(
"unignored-root",
`Configured worktree root ${relativeRoot} must already be ignored by Git`,
);
}
const worktreePath = path.join(
worktreeRoot,
`pi-workflow-${options.featureName}-${options.runId}`,
);
try {
await lstat(worktreePath);
throw new WorkspaceValidationError(
"existing-worktree-path",
`Worktree path already exists: ${worktreePath}`,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const branch = `feature/${options.featureName}`;
const branchCheck = await this.git(sourceRoot, [
"show-ref",
"--verify",
"--quiet",
`refs/heads/${branch}`,
]);
if (branchCheck.code !== 0 && branchCheck.code !== 1) {
throw new WorkspaceValidationError(
"git-failed",
`Unable to inspect branch ${branch}: ${branchCheck.stderr.trim()}`,
);
}
const head = await this.git(sourceRoot, ["rev-parse", "HEAD"]);
if (head.code !== 0) {
throw new WorkspaceValidationError(
"git-failed",
`Unable to resolve HEAD: ${head.stderr.trim()}`,
);
}
return {
sourceRoot,
worktreeRoot,
worktreePath,
branch,
baseHead: head.stdout.trim(),
branchExists: branchCheck.code === 0,
};
}
async create(plan: WorkspacePlan): Promise<WorkflowWorkspace> {
const branch = await this.git(plan.sourceRoot, [
"show-ref",
"--verify",
"--quiet",
`refs/heads/${plan.branch}`,
]);
if (branch.code !== 0 && branch.code !== 1) {
return {
state: "creating",
sourceRoot: plan.sourceRoot,
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
error: `Unable to inspect partial branch creation: ${branch.stderr.trim()}`,
};
}
let addArguments = [
"worktree",
"add",
"-b",
plan.branch,
plan.worktreePath,
plan.baseHead,
];
if (branch.code === 0) {
const branchHead = await this.git(plan.sourceRoot, [
"rev-parse",
`refs/heads/${plan.branch}`,
]);
if (branchHead.code !== 0 || branchHead.stdout.trim() !== plan.baseHead) {
return {
state: "failed",
sourceRoot: plan.sourceRoot,
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
error: `Existing partial branch ${plan.branch} does not match persisted base HEAD ${plan.baseHead}`,
};
}
addArguments = ["worktree", "add", plan.worktreePath, plan.branch];
}
const result = await this.git(plan.sourceRoot, addArguments);
if (result.code !== 0) {
return this.reconcile({
state: "creating",
sourceRoot: plan.sourceRoot,
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
error: `Git worktree creation returned ${result.code}: ${result.stderr.trim()}`,
});
}
return {
state: "ready",
sourceRoot: plan.sourceRoot,
root: plan.worktreePath,
branch: plan.branch,
baseHead: plan.baseHead,
};
}
async reconcile(
workspace: WorkflowWorkspace,
expectedWorktreeIdentity = workspace.root,
): Promise<WorkflowWorkspace> {
if (
workspace.state !== "creating" ||
!workspace.root ||
!workspace.branch
) {
return workspace;
}
const listed = await this.git(workspace.sourceRoot, [
"worktree",
"list",
"--porcelain",
]);
if (listed.code !== 0) {
return {
...workspace,
state: "failed",
error: `Unable to reconcile worktree: ${listed.stderr.trim()}`,
};
}
const expectedRoot = path.resolve(workspace.root);
if (
!expectedWorktreeIdentity ||
path.resolve(expectedWorktreeIdentity) !== expectedRoot
) {
return {
...workspace,
state: "failed",
error: "Persisted worktree lease identity does not match its workspace",
};
}
if (!workspace.baseHead) {
return {
...workspace,
state: "failed",
error: "Persisted worktree base HEAD is missing",
};
}
const blocks = listed.stdout
.split(/\n\n+/)
.map((block) => block.split("\n"));
const expectedBranch = `branch refs/heads/${workspace.branch}`;
const matchingPath = blocks.find((lines) =>
lines.some(
(line) =>
line.startsWith("worktree ") &&
path.resolve(line.slice("worktree ".length)) === expectedRoot,
),
);
const matchingBranch = blocks.find((lines) =>
lines.includes(expectedBranch),
);
if (!matchingPath && !matchingBranch) {
return { ...workspace, state: "creating" };
}
const expectedHead = `HEAD ${workspace.baseHead}`;
if (
matchingPath === matchingBranch &&
matchingPath?.includes(expectedHead)
) {
return { ...workspace, state: "ready", error: undefined };
}
return {
...workspace,
state: "failed",
error:
"Interrupted worktree creation conflicts with the persisted path, branch, or base HEAD",
};
}
private async containedWorktreeRoot(
sourceRoot: string,
configuredRoot: string,
) {
const requested = path.resolve(sourceRoot, configuredRoot);
const relative = path.relative(sourceRoot, requested);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new WorkspaceValidationError(
"unignored-root",
"Configured worktree root must be a directory inside the repository",
);
}
let current = sourceRoot;
for (const segment of relative.split(path.sep)) {
current = path.join(current, segment);
let info;
try {
info = await lstat(current);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") break;
throw error;
}
if (info.isSymbolicLink()) {
throw new WorkspaceValidationError(
"unignored-root",
`Configured worktree root cannot traverse symlink ${current}`,
);
}
}
const canonicalParent = await realpath(path.dirname(current));
const canonicalRelative = path.relative(sourceRoot, canonicalParent);
if (
canonicalRelative.startsWith("..") ||
path.isAbsolute(canonicalRelative)
) {
throw new WorkspaceValidationError(
"unignored-root",
"Configured worktree root resolves outside the repository",
);
}
return requested;
}
private git(cwd: string, args: string[]) {
return this.runner.run(cwd, "git", args);
}
}