feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
@@ -20,11 +20,13 @@ import {
|
||||
FilesystemPrototypeValidator,
|
||||
GitStateProbe,
|
||||
GitWorkspaceManager,
|
||||
INTERNAL_GIT_TIMEOUT_MS,
|
||||
MutationScope,
|
||||
WorkspaceSafetyError,
|
||||
WorkspaceValidationError,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
type CommandRunOptions,
|
||||
} from "./workspace.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -80,16 +82,25 @@ async function withTempDir(run: (directory: string) => Promise<void>) {
|
||||
}
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
readonly calls: Array<{ cwd: string; executable: string; args: string[] }> =
|
||||
[];
|
||||
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[]) {
|
||||
this.calls.push({ cwd, executable, args: [...args] });
|
||||
async run(
|
||||
cwd: string,
|
||||
executable: string,
|
||||
args: string[],
|
||||
options?: CommandRunOptions,
|
||||
) {
|
||||
this.calls.push({ cwd, executable, args: [...args], options });
|
||||
return this.respond(args);
|
||||
}
|
||||
}
|
||||
@@ -100,14 +111,23 @@ function result(code = 0, stdout = "", stderr = ""): CommandResult {
|
||||
|
||||
function prepareResponses(
|
||||
repository: string,
|
||||
options: { dirty?: boolean; ignored?: boolean; branchExists?: boolean } = {},
|
||||
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 --untracked-files=normal") {
|
||||
return result(0, options.dirty ? " M src/file.ts\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);
|
||||
@@ -481,6 +501,59 @@ test("Git probe and approved host commands enforce identity and ownership", asyn
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
@@ -557,6 +630,11 @@ test("workspace preparation validates Git state and builds the exact feature bra
|
||||
"abc123",
|
||||
]);
|
||||
assert.ok(runner.calls.every((call) => call.executable === "git"));
|
||||
assert.ok(
|
||||
runner.calls.every(
|
||||
(call) => call.options?.timeoutMs === INTERNAL_GIT_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -602,7 +680,7 @@ test("real Git creation and reconciliation preserve branch and HEAD identity", a
|
||||
});
|
||||
});
|
||||
|
||||
test("dirty sources and unignored worktree roots fail before creation", async () => {
|
||||
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 })),
|
||||
@@ -614,9 +692,42 @@ test("dirty sources and unignored worktree roots fail before creation", async ()
|
||||
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 === "dirty-source",
|
||||
error.code === "unmerged-source",
|
||||
);
|
||||
|
||||
const unignored = new GitWorkspaceManager(
|
||||
@@ -743,6 +854,9 @@ test("uncertain worktree-add results are reconciled before returning", async ()
|
||||
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(
|
||||
@@ -786,6 +900,9 @@ test("retry adopts an exact orphan branch left by interrupted creation", async (
|
||||
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, [
|
||||
|
||||
Reference in New Issue
Block a user