feat: add snapshot-based none VCS mode

This commit is contained in:
liujing
2026-07-17 11:50:53 +08:00
parent 758c41a953
commit b756a10c35
11 changed files with 1336 additions and 121 deletions
+543
View File
@@ -0,0 +1,543 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import {
detectVcs,
NoneProvider,
} from "./vcs-provider.js";
import { resolveVcsSelection, startWorkflow, reviewWorkflow } from "./workflow-engine.js";
import { findRunDir, readState, releaseLock } from "./workflow-state.js";
import type { WorkflowPlanV1 } from "./workflow-types.js";
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-none-test-"));
}
function cleanupDir(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
function makeValidPlan(scope?: string[]): WorkflowPlanV1 {
return {
version: "1",
title: "Test plan",
planMarkdown: "Do something.",
scope: scope ?? ["src/"],
acceptanceCriteria: ["Tests pass"],
verificationCommands: [["echo", "test"]],
};
}
describe("None VCS Provider (Snapshot Mode)", () => {
let tmpDir: string;
let runDir: string;
let stateRoot: string;
let fakeBinDir: string;
let originalWorkflowDir: string | undefined;
beforeEach(() => {
tmpDir = makeTmpDir();
runDir = makeTmpDir();
stateRoot = makeTmpDir(); // Keep stateRoot completely outside target tmpDir
fakeBinDir = path.join(tmpDir, "bin");
fs.mkdirSync(fakeBinDir, { recursive: true });
const fakeBin = path.join(fakeBinDir, "reasonix");
fs.writeFileSync(
fakeBin,
[`#!/bin/bash`, `echo '{"session_id":"test-session"}'`, `exit 0`].join("\n"),
{ mode: 0o755 }
);
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
});
afterEach(() => {
if (originalWorkflowDir === undefined) {
delete process.env.AGENT_WORKFLOW_DIR;
} else {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
}
cleanupDir(tmpDir);
cleanupDir(runDir);
cleanupDir(stateRoot);
});
it("automatically selects none in a non-VCS directory", async () => {
expect(detectVcs(tmpDir)).toBe("none");
const selection = await resolveVcsSelection(tmpDir);
expect(selection.vcsKind).toBe("none");
expect(selection.repoRoot).toBe(tmpDir);
});
it("fails on explicit --vcs git or svn in a non-VCS directory, but --vcs none works", async () => {
await expect(resolveVcsSelection(tmpDir, "git")).rejects.toThrow();
await expect(resolveVcsSelection(tmpDir, "svn")).rejects.toThrow();
const selection = await resolveVcsSelection(tmpDir, "none");
expect(selection.vcsKind).toBe("none");
expect(selection.repoRoot).toBe(tmpDir);
});
it("honors defaultVcs: 'none' project configuration", async () => {
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "none" }), "utf8");
const selection = await resolveVcsSelection(tmpDir);
expect(selection.vcsKind).toBe("none");
});
it("honors defaultVcs: 'git' or 'svn' but fails in non-VCS directories", async () => {
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "git" }), "utf8");
await expect(resolveVcsSelection(tmpDir)).rejects.toThrow(/Configured VCS is "git"/);
});
it("records snapshot files and detects additions, modifications, and deletions including empty, unicode, special-character, and binary files", () => {
const provider = new NoneProvider();
// Setup initial files
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8");
// Create binary file
const binaryData = Buffer.from([1, 2, 0, 4, 5]);
fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), binaryData);
// Unicode path
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8");
// Special characters path
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8");
// Capture baseline
const baseline = provider.captureBaseline(tmpDir, runDir);
expect(baseline.kind).toBe("none");
// Check no differences initially
let diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toBe("");
// 1. Text modification/addition/deletion
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/text.txt b/src/text.txt");
expect(diff).toContain("-hello world");
expect(diff).toContain("+hello world modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/text.txt");
// Revert text.txt
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8");
// Add new text file
fs.writeFileSync(path.join(tmpDir, "src", "added.txt"), "new content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/added.txt b/src/added.txt");
expect(diff).toContain("+new content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/added.txt");
// Delete it
fs.unlinkSync(path.join(tmpDir, "src", "added.txt"));
// 2. Empty file modification/addition/deletion
// Add new empty file
fs.writeFileSync(path.join(tmpDir, "src", "new-empty.txt"), "", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/new-empty.txt b/src/new-empty.txt");
expect(diff).toContain("new file mode 100644");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new-empty.txt");
fs.unlinkSync(path.join(tmpDir, "src", "new-empty.txt"));
// Modify existing empty.txt
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "no longer empty\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt");
expect(diff).toContain("+no longer empty");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore
// Delete empty.txt
fs.unlinkSync(path.join(tmpDir, "src", "empty.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt");
expect(diff).toContain("deleted file mode 100644");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore
// 3. Unicode path modification/addition/deletion
// Add new Unicode path
fs.writeFileSync(path.join(tmpDir, "src", "新建文件.txt"), "unicode content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/新建文件.txt b/src/新建文件.txt");
expect(diff).toContain("+unicode content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/新建文件.txt");
fs.unlinkSync(path.join(tmpDir, "src", "新建文件.txt"));
// Modify existing 中文.txt
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt");
expect(diff).toContain("+unicode test modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt");
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore
// Delete 中文.txt
fs.unlinkSync(path.join(tmpDir, "src", "中文.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt");
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore
// 4. Special characters path modification/addition/deletion
// Add new special char path
fs.writeFileSync(path.join(tmpDir, "src", "new special#char$path.txt"), "special content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/new special#char$path.txt b/src/new special#char$path.txt");
expect(diff).toContain("+special content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new special#char$path.txt");
fs.unlinkSync(path.join(tmpDir, "src", "new special#char$path.txt"));
// Modify special space & char.txt
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt");
expect(diff).toContain("+special characters modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt");
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore
// Delete special space & char.txt
fs.unlinkSync(path.join(tmpDir, "src", "special space & char.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt");
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore
// 5. Binary file modification/deletion
fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), Buffer.from([1, 2, 0, 4, 6]));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin");
expect(diff).toContain("Binary files a/src/binary.bin and b/src/binary.bin differ");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin");
fs.unlinkSync(path.join(tmpDir, "src", "binary.bin"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin");
expect(diff).toContain("Binary files a/src/binary.bin and /dev/null differ");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin");
});
it("respects ignore patterns from .gitignore and .agent-workflowignore", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "ignored-dir"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content", "utf8");
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "b.txt"), "content", "utf8");
// Add gitignore ignoring ignored-dir/
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n*.log\n", "utf8");
fs.writeFileSync(path.join(tmpDir, "src", "test.log"), "log content", "utf8");
// Add agent-workflowignore with negative rule
fs.writeFileSync(path.join(tmpDir, ".agent-workflowignore"), "!test.log\n", "utf8");
// Let's capture baseline
const baseline = provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
const files = Object.keys(snapshot.files);
// .git and .svn are always excluded
expect(files).not.toContain(".git");
expect(files).not.toContain(".svn");
// ignored-dir/ is ignored by .gitignore
expect(files).not.toContain("ignored-dir/b.txt");
// test.log is ignored by *.log in .gitignore but negated by !test.log in .agent-workflowignore
expect(files).toContain("src/test.log");
});
it("does not follow symbolic links, only records link target", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "target.txt"), "target content\n", "utf8");
// Create a symlink to target.txt
try {
fs.symlinkSync("target.txt", path.join(tmpDir, "src", "link.txt"));
} catch {
// Symlink support might require admin privileges on some systems (e.g. Windows),
// but on Linux sandbox it works.
}
if (fs.existsSync(path.join(tmpDir, "src", "link.txt"))) {
const baseline = provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
expect(snapshot.files["src/link.txt"]).toBeDefined();
expect(snapshot.files["src/link.txt"].type).toBe("symlink");
expect(snapshot.files["src/link.txt"].target).toBe("target.txt");
}
});
it("fails drift check/validation if snapshot is missing or corrupted", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Works originally
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).not.toThrow();
// Missing snapshot file
fs.unlinkSync(path.join(runDir, "baseline-snapshot.json"));
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Baseline snapshot file is missing/);
// Corrupted snapshot file (testing invalid JSON: we must update hash to match corrupted content so hash check passes but parse fails)
const corruptedContent = "{invalid-json}";
const corruptedHash = crypto.createHash("sha256").update(Buffer.from(corruptedContent, "utf8")).digest("hex");
fs.writeFileSync(path.join(runDir, "baseline-snapshot.json"), corruptedContent, "utf8");
const corruptedBaseline = { ...baseline, snapshotHash: corruptedHash };
expect(() => provider.validateBaseline(tmpDir, corruptedBaseline, runDir)).toThrow(/Baseline snapshot is not valid JSON/);
});
it("fails validation/drift check if snapshot is tampered with using different valid-JSON", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Tamper with snapshot by writing a different valid JSON like {files:{}}
const snapshotPath = path.join(runDir, "baseline-snapshot.json");
fs.writeFileSync(snapshotPath, JSON.stringify({ files: {} }), "utf8");
// Must fail due to hash mismatch
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Hash mismatch/);
});
it("fails validation/drift check if snapshotFile traverses outside runDir", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Attempt path traversal using relative parent directory
const traversalBaseline = { ...baseline, snapshotFile: "../outside.json" };
expect(() => provider.validateBaseline(tmpDir, traversalBaseline, runDir)).toThrow(/Access denied/);
// Attempt path traversal using absolute path
const absoluteBaseline = { ...baseline, snapshotFile: "/etc/passwd" };
expect(() => provider.validateBaseline(tmpDir, absoluteBaseline, runDir)).toThrow(/Access denied/);
});
it("proves mutable ignore files cannot hide scope violations after baseline capture", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
// Capture baseline with no ignores initially
const baseline = provider.captureBaseline(tmpDir, runDir);
// Create a file outside the scope and then write a gitignore ignoring it
fs.writeFileSync(path.join(tmpDir, "secret.txt"), "secret\n", "utf8");
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "secret.txt\n", "utf8");
// The scan must reuse the frozen ignore policy (no ignores),
// so secret.txt must still be detected as changed/out-of-scope!
const changed = provider.checkFilesInScope(tmpDir, baseline, ["src/"], runDir);
expect(changed).toContain("secret.txt");
const diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/secret.txt b/secret.txt");
});
it("proves admin negations stay unconditionally excluded", () => {
const provider = new NoneProvider();
// Create admin directories and files
fs.mkdirSync(path.join(tmpDir, ".git"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, ".git", "config"), "some config\n", "utf8");
fs.mkdirSync(path.join(tmpDir, ".svn"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, ".svn", "entries"), "some entries\n", "utf8");
// Write negations
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "!.git\n!.git/**\n!.svn\n!.svn/**\n", "utf8");
provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
const files = Object.keys(snapshot.files);
expect(files).not.toContain(".git/config");
expect(files).not.toContain(".git");
expect(files).not.toContain(".svn/entries");
expect(files).not.toContain(".svn");
});
it("proves CLI help exposes none under --vcs", async () => {
const { WORKFLOW_USAGE } = await import("./workflow-args.js");
expect(WORKFLOW_USAGE).toContain("--vcs <git|svn|none>");
});
it("rejects starting a workflow if AGENT_WORKFLOW_DIR is equal to or nested under repoRoot", async () => {
const nestedStateRoot = path.join(tmpDir, "nested-workflows");
process.env.AGENT_WORKFLOW_DIR = nestedStateRoot;
const planFile = path.join(tmpDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
await expect(startWorkflow({
planInput: planFile,
executor: "reasonix",
cwd: tmpDir,
})).rejects.toThrow(/cannot be equal to or nested under the repository root/);
});
it("preserves project configuration from parent root with defaultVcs:none while keeping repoRoot as cwd", async () => {
const parentDir = path.join(tmpDir, "parent");
const subDir = path.join(parentDir, "sub");
fs.mkdirSync(subDir, { recursive: true });
// Initialize parentDir as a Git repository root so findVcsCandidates resolves it
execFileSync("git", ["init"], { cwd: parentDir, stdio: "ignore" });
// Write config at parent
const configPath = path.join(parentDir, "agent-workflow.json");
fs.writeFileSync(
configPath,
JSON.stringify({
version: "1",
defaultVcs: "none",
maxCycles: 9,
timeoutSeconds: 99,
defaultExecutor: "agy",
executors: {
agy: {
binary: path.join(fakeBinDir, "reasonix")
}
}
}),
"utf8"
);
// Write plan in subDir
const planFile = path.join(subDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
// Start workflow from subDir
const { runId } = await startWorkflow({
planInput: planFile,
cwd: subDir,
});
const wDir = findRunDir(runId, subDir);
expect(wDir).toBeDefined();
const state = readState(wDir!);
expect(state).toBeDefined();
expect(state!.vcsKind).toBe("none");
expect(state!.repoRoot).toBe(subDir); // remains invocation cwd
expect(state!.maxCycles).toBe(9); // preserved from parent config
expect(state!.timeoutSeconds).toBe(99); // preserved from parent config
expect(state!.executor).toBe("agy"); // preserved from parent config
releaseLock(subDir, runId);
// Assert that CLI overrides still take precedence
const { runId: runId2 } = await startWorkflow({
planInput: planFile,
cwd: subDir,
maxCycles: 3,
});
const wDir2 = findRunDir(runId2, subDir);
const state2 = readState(wDir2!);
expect(state2!.maxCycles).toBe(3); // CLI override takes precedence!
releaseLock(subDir, runId2);
});
it("runs a full none-mode workflow: start, modify in-scope, review, detect out-of-scope changes, and block review", async () => {
const fakeBin = path.join(fakeBinDir, "reasonix");
// Setup working files
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(1)\n", "utf8");
// Setup project config
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(
configPath,
JSON.stringify({
version: "1",
defaultVcs: "none",
executors: {
reasonix: {
binary: fakeBin,
},
},
}),
"utf8"
);
// Setup plan
const planFile = path.join(tmpDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
// Start workflow
const { runId } = await startWorkflow({
planInput: planFile,
executor: "reasonix",
cwd: tmpDir,
});
const wDir = findRunDir(runId, tmpDir);
expect(wDir).toBeDefined();
const state = readState(wDir!);
expect(state).toBeDefined();
expect(state!.vcsKind).toBe("none");
expect(state!.vcsBaseline?.kind).toBe("none");
expect(state!.status).toBe("awaiting_review"); // Assert startWorkflow naturally reaches awaiting_review
// Modify a file in scope
fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(2)\n", "utf8");
// reviewWorkflow must succeed (generates diff, validates scope)
await reviewWorkflow({ runId, cwd: tmpDir });
const updatedState = readState(wDir!);
expect(updatedState!.status).toBe("awaiting_host");
// Now modify a file out of scope
fs.writeFileSync(path.join(tmpDir, "outside.txt"), "hack\n", "utf8");
// Reset status back to awaiting_review
updatedState!.status = "awaiting_review";
fs.writeFileSync(path.join(wDir!, "state.json"), JSON.stringify(updatedState, null, 2), "utf8");
// reviewWorkflow must transition run to awaiting_scope_resolution due to out-of-scope files
await reviewWorkflow({ runId, cwd: tmpDir });
const blockedState = readState(wDir!);
expect(blockedState!.status).toBe("awaiting_scope_resolution");
// Clean up locks
releaseLock(tmpDir, runId);
});
});