544 lines
23 KiB
TypeScript
544 lines
23 KiB
TypeScript
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);
|
|
});
|
|
});
|