feat: add snapshot-based none VCS mode
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user