feat: add snapshot-based none VCS mode
This commit is contained in:
@@ -147,6 +147,7 @@ export type {
|
||||
VcsBaseline,
|
||||
VcsBaselineGit,
|
||||
VcsBaselineSvn,
|
||||
VcsBaselineNone,
|
||||
ExtensionRecord,
|
||||
} from "./workflow-types.js";
|
||||
|
||||
@@ -175,6 +176,7 @@ export {
|
||||
VcsError,
|
||||
GitProvider,
|
||||
SvnProvider,
|
||||
NoneProvider,
|
||||
detectVcs,
|
||||
createVcsProvider,
|
||||
extractBaselineHead,
|
||||
|
||||
+590
-15
@@ -7,9 +7,12 @@
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import ignore from "ignore";
|
||||
import type { VcsBaseline, VcsKind } from "./workflow-types.js";
|
||||
|
||||
export class VcsError extends Error {
|
||||
@@ -30,19 +33,19 @@ export interface VcsProvider {
|
||||
repoRoot(cwd: string): string;
|
||||
|
||||
/** Capture the current baseline state. */
|
||||
captureBaseline(repoRoot: string): VcsBaseline;
|
||||
captureBaseline(repoRoot: string, runDir?: string): VcsBaseline;
|
||||
|
||||
/** Check if the working copy is clean (no uncommitted changes). */
|
||||
isClean(repoRoot: string): boolean;
|
||||
|
||||
/** Generate a diff from the baseline to the current working tree. */
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string;
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline, runDir?: string): string;
|
||||
|
||||
/** Check that all modified files are within the allowed scope. */
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[];
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[], runDir?: string): string[];
|
||||
|
||||
/** Validate that the baseline hasn't drifted (no commits/updates). */
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline): void;
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline, runDir?: string): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,7 +67,7 @@ export class GitProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
captureBaseline(repoRoot: string): VcsBaseline {
|
||||
captureBaseline(repoRoot: string, _runDir?: string): VcsBaseline {
|
||||
try {
|
||||
const head = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
@@ -90,7 +93,7 @@ export class GitProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline, _runDir?: string): string {
|
||||
if (baseline.kind !== "git") {
|
||||
throw new VcsError("Git provider requires git baseline.");
|
||||
}
|
||||
@@ -132,7 +135,7 @@ export class GitProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[], _runDir?: string): string[] {
|
||||
if (baseline.kind !== "git") {
|
||||
throw new VcsError("Git provider requires git baseline.");
|
||||
}
|
||||
@@ -168,7 +171,7 @@ export class GitProvider implements VcsProvider {
|
||||
return outOfScope;
|
||||
}
|
||||
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline): void {
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline, _runDir?: string): void {
|
||||
if (baseline.kind !== "git") {
|
||||
throw new VcsError("Git provider requires git baseline.");
|
||||
}
|
||||
@@ -204,7 +207,7 @@ export class SvnProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
captureBaseline(repoRoot: string): VcsBaseline {
|
||||
captureBaseline(repoRoot: string, _runDir?: string): VcsBaseline {
|
||||
// Strict validation before capturing baseline
|
||||
this.validateStrictWorkingCopy(repoRoot);
|
||||
|
||||
@@ -259,7 +262,7 @@ export class SvnProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline, _runDir?: string): string {
|
||||
if (baseline.kind !== "svn") {
|
||||
throw new VcsError("SVN provider requires svn baseline.");
|
||||
}
|
||||
@@ -391,7 +394,7 @@ export class SvnProvider implements VcsProvider {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[], _runDir?: string): string[] {
|
||||
if (baseline.kind !== "svn") {
|
||||
throw new VcsError("SVN provider requires svn baseline.");
|
||||
}
|
||||
@@ -434,7 +437,7 @@ export class SvnProvider implements VcsProvider {
|
||||
}
|
||||
}
|
||||
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline): void {
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline, _runDir?: string): void {
|
||||
if (baseline.kind !== "svn") {
|
||||
throw new VcsError("SVN provider requires svn baseline.");
|
||||
}
|
||||
@@ -728,6 +731,578 @@ interface SvnStatusEntry {
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// None Provider (Snapshot Mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FileSnapshotText {
|
||||
type: "text";
|
||||
hash: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FileSnapshotBinary {
|
||||
type: "binary";
|
||||
hash: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface FileSnapshotSymlink {
|
||||
type: "symlink";
|
||||
target: string;
|
||||
}
|
||||
|
||||
type FileSnapshot = FileSnapshotText | FileSnapshotBinary | FileSnapshotSymlink;
|
||||
|
||||
interface BaselineSnapshot {
|
||||
files: { [posixPath: string]: FileSnapshot };
|
||||
ignores?: {
|
||||
gitignore?: string;
|
||||
workflowignore?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getIgnoreManager(
|
||||
repoRoot: string,
|
||||
frozenIgnores?: { gitignore?: string; workflowignore?: string }
|
||||
): ReturnType<typeof ignore> {
|
||||
const ig = ignore();
|
||||
|
||||
if (frozenIgnores) {
|
||||
if (frozenIgnores.gitignore) {
|
||||
ig.add(frozenIgnores.gitignore);
|
||||
}
|
||||
if (frozenIgnores.workflowignore) {
|
||||
ig.add(frozenIgnores.workflowignore);
|
||||
}
|
||||
} else {
|
||||
const gitignorePath = path.join(repoRoot, ".gitignore");
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
try {
|
||||
ig.add(fs.readFileSync(gitignorePath, "utf8"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const workflowignorePath = path.join(repoRoot, ".agent-workflowignore");
|
||||
if (fs.existsSync(workflowignorePath)) {
|
||||
try {
|
||||
ig.add(fs.readFileSync(workflowignorePath, "utf8"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional exclusions added after user rules so negations cannot override them
|
||||
ig.add([".git", ".git/**", ".svn", ".svn/**"]);
|
||||
|
||||
return ig;
|
||||
}
|
||||
|
||||
function walkDir(
|
||||
dir: string,
|
||||
repoRoot: string,
|
||||
ig: ReturnType<typeof ignore>,
|
||||
files: { [relPath: string]: fs.Stats }
|
||||
) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry === ".git" || entry === ".svn") {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(dir, entry);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(fullPath);
|
||||
} catch {
|
||||
throw new VcsError(`Failed to lstat ${fullPath}`);
|
||||
}
|
||||
|
||||
const relPath = path.relative(repoRoot, fullPath);
|
||||
const posixPath = relPath.split(path.sep).join("/");
|
||||
const isDir = stat.isDirectory();
|
||||
const finalPath = isDir ? `${posixPath}/` : posixPath;
|
||||
|
||||
if (ig.ignores(finalPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
files[posixPath] = stat;
|
||||
} else if (isDir) {
|
||||
walkDir(fullPath, repoRoot, ig, files);
|
||||
} else if (stat.isFile()) {
|
||||
files[posixPath] = stat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBinary(buf: Buffer): boolean {
|
||||
const limit = Math.min(buf.length, 8000);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
if (buf[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function generateSnapshot(
|
||||
repoRoot: string,
|
||||
frozenIgnores?: { gitignore?: string; workflowignore?: string }
|
||||
): BaselineSnapshot {
|
||||
const ig = getIgnoreManager(repoRoot, frozenIgnores);
|
||||
const filesStats: { [relPath: string]: fs.Stats } = {};
|
||||
try {
|
||||
walkDir(repoRoot, repoRoot, ig, filesStats);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) throw err;
|
||||
throw new VcsError(`Failed to scan repository: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const snapshot: BaselineSnapshot = { files: {} };
|
||||
for (const [posixPath, stat] of Object.entries(filesStats)) {
|
||||
const fullPath = path.join(repoRoot, posixPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
try {
|
||||
const target = fs.readlinkSync(fullPath);
|
||||
snapshot.files[posixPath] = {
|
||||
type: "symlink",
|
||||
target,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new VcsError(`Failed to read symlink ${fullPath}: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
let contentBuf: Buffer;
|
||||
try {
|
||||
contentBuf = fs.readFileSync(fullPath);
|
||||
} catch (err) {
|
||||
throw new VcsError(`Failed to read file ${fullPath}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const hash = crypto.createHash("sha256").update(contentBuf).digest("hex");
|
||||
|
||||
if (isBinary(contentBuf)) {
|
||||
snapshot.files[posixPath] = {
|
||||
type: "binary",
|
||||
hash,
|
||||
size: stat.size,
|
||||
};
|
||||
} else {
|
||||
snapshot.files[posixPath] = {
|
||||
type: "text",
|
||||
hash,
|
||||
content: contentBuf.toString("utf8"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function readBaselineSnapshot(baseline: VcsBaseline, runDir?: string): BaselineSnapshot {
|
||||
if (baseline.kind !== "none") {
|
||||
throw new VcsError("None provider requires none baseline.");
|
||||
}
|
||||
if (!runDir) {
|
||||
throw new VcsError("Run directory is required to read baseline snapshot.");
|
||||
}
|
||||
if (!baseline.snapshotFile) {
|
||||
throw new VcsError("Baseline missing required field: snapshotFile");
|
||||
}
|
||||
if (!baseline.snapshotHash) {
|
||||
throw new VcsError("Baseline missing required field: snapshotHash");
|
||||
}
|
||||
|
||||
const snapshotFile = baseline.snapshotFile;
|
||||
if (path.isAbsolute(snapshotFile)) {
|
||||
throw new VcsError(`Access denied: baseline snapshot file cannot be an absolute path.`);
|
||||
}
|
||||
|
||||
const absRunDir = path.resolve(runDir);
|
||||
const resolvedSnapshotPath = path.resolve(absRunDir, snapshotFile);
|
||||
const relative = path.relative(absRunDir, resolvedSnapshotPath);
|
||||
const outside = relative.startsWith("..") || path.isAbsolute(relative) || relative === "";
|
||||
if (outside) {
|
||||
throw new VcsError(`Access denied: baseline snapshot file location is invalid or outside the run directory.`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedSnapshotPath)) {
|
||||
throw new VcsError(`Baseline snapshot file is missing: ${resolvedSnapshotPath}`);
|
||||
}
|
||||
|
||||
let contentBuf: Buffer;
|
||||
try {
|
||||
contentBuf = fs.readFileSync(resolvedSnapshotPath);
|
||||
} catch (err) {
|
||||
throw new VcsError(`Failed to read baseline snapshot: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 1. Verify the exact bytes before every parse/use (SHA-256 hash check)
|
||||
const hash = crypto.createHash("sha256").update(contentBuf).digest("hex");
|
||||
if (hash !== baseline.snapshotHash) {
|
||||
throw new VcsError(`Baseline snapshot integrity check failed: Hash mismatch (expected ${baseline.snapshotHash}, got ${hash})`);
|
||||
}
|
||||
|
||||
// 2. Parse JSON as unknown
|
||||
let snapshot: unknown;
|
||||
try {
|
||||
snapshot = JSON.parse(contentBuf.toString("utf8"));
|
||||
} catch (err) {
|
||||
throw new VcsError(`Baseline snapshot is not valid JSON: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 3. Validate parsed schema
|
||||
if (!snapshot || typeof snapshot !== "object") {
|
||||
throw new VcsError("Baseline snapshot schema validation failed: root must be an object.");
|
||||
}
|
||||
|
||||
const snapshotObj = snapshot as Record<string, unknown>;
|
||||
if (!snapshotObj.files || typeof snapshotObj.files !== "object" || snapshotObj.files === null) {
|
||||
throw new VcsError("Baseline snapshot schema validation failed: 'files' must be an object.");
|
||||
}
|
||||
|
||||
const filesObj = snapshotObj.files as Record<string, unknown>;
|
||||
const validatedFiles: { [posixPath: string]: FileSnapshot } = {};
|
||||
|
||||
for (const [posixPath, fileEntry] of Object.entries(filesObj)) {
|
||||
if (!fileEntry || typeof fileEntry !== "object") {
|
||||
throw new VcsError(`Baseline snapshot schema validation failed: file entry at '${posixPath}' must be an object.`);
|
||||
}
|
||||
const entry = fileEntry as Record<string, unknown>;
|
||||
const type = entry.type;
|
||||
if (type !== "text" && type !== "binary" && type !== "symlink") {
|
||||
throw new VcsError(`Baseline snapshot schema validation failed: unknown type '${type}' at '${posixPath}'.`);
|
||||
}
|
||||
if (type === "text") {
|
||||
if (typeof entry.hash !== "string" || typeof entry.content !== "string") {
|
||||
throw new VcsError(`Baseline snapshot schema validation failed: text file at '${posixPath}' missing hash or content.`);
|
||||
}
|
||||
validatedFiles[posixPath] = {
|
||||
type: "text",
|
||||
hash: entry.hash,
|
||||
content: entry.content,
|
||||
};
|
||||
} else if (type === "binary") {
|
||||
if (typeof entry.hash !== "string" || typeof entry.size !== "number") {
|
||||
throw new VcsError(`Baseline snapshot schema validation failed: binary file at '${posixPath}' missing hash or size.`);
|
||||
}
|
||||
validatedFiles[posixPath] = {
|
||||
type: "binary",
|
||||
hash: entry.hash,
|
||||
size: entry.size,
|
||||
};
|
||||
} else if (type === "symlink") {
|
||||
if (typeof entry.target !== "string") {
|
||||
throw new VcsError(`Baseline snapshot schema validation failed: symlink at '${posixPath}' missing target.`);
|
||||
}
|
||||
validatedFiles[posixPath] = {
|
||||
type: "symlink",
|
||||
target: entry.target,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let validatedIgnores: { gitignore?: string; workflowignore?: string } | undefined;
|
||||
if (snapshotObj.ignores !== undefined) {
|
||||
if (snapshotObj.ignores === null || typeof snapshotObj.ignores !== "object") {
|
||||
throw new VcsError("Baseline snapshot schema validation failed: 'ignores' must be an object.");
|
||||
}
|
||||
const ignoresObj = snapshotObj.ignores as Record<string, unknown>;
|
||||
validatedIgnores = {};
|
||||
if (ignoresObj.gitignore !== undefined) {
|
||||
if (typeof ignoresObj.gitignore !== "string") {
|
||||
throw new VcsError("Baseline snapshot schema validation failed: 'ignores.gitignore' must be a string.");
|
||||
}
|
||||
validatedIgnores.gitignore = ignoresObj.gitignore;
|
||||
}
|
||||
if (ignoresObj.workflowignore !== undefined) {
|
||||
if (typeof ignoresObj.workflowignore !== "string") {
|
||||
throw new VcsError("Baseline snapshot schema validation failed: 'ignores.workflowignore' must be a string.");
|
||||
}
|
||||
validatedIgnores.workflowignore = ignoresObj.workflowignore;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files: validatedFiles,
|
||||
ignores: validatedIgnores,
|
||||
};
|
||||
}
|
||||
|
||||
function getChangedFiles(repoRoot: string, baseline: BaselineSnapshot): string[] {
|
||||
const current = generateSnapshot(repoRoot, baseline.ignores);
|
||||
const changed: string[] = [];
|
||||
|
||||
const allPaths = new Set([
|
||||
...Object.keys(baseline.files),
|
||||
...Object.keys(current.files)
|
||||
]);
|
||||
|
||||
for (const posixPath of allPaths) {
|
||||
const baseFile = baseline.files[posixPath];
|
||||
const currFile = current.files[posixPath];
|
||||
|
||||
if (!baseFile || !currFile) {
|
||||
changed.push(posixPath);
|
||||
} else if (baseFile.type !== currFile.type) {
|
||||
changed.push(posixPath);
|
||||
} else if (baseFile.type === "text" && currFile.type === "text") {
|
||||
if (baseFile.hash !== currFile.hash) {
|
||||
changed.push(posixPath);
|
||||
}
|
||||
} else if (baseFile.type === "binary" && currFile.type === "binary") {
|
||||
if (baseFile.hash !== currFile.hash) {
|
||||
changed.push(posixPath);
|
||||
}
|
||||
} else if (baseFile.type === "symlink" && currFile.type === "symlink") {
|
||||
if (baseFile.target !== currFile.target) {
|
||||
changed.push(posixPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed.sort();
|
||||
}
|
||||
|
||||
function diffStrings(labelA: string, labelB: string, contentA: string, contentB: string): string {
|
||||
const tempDir = os.tmpdir();
|
||||
const fileA = path.join(tempDir, `agy-diff-a-${crypto.randomBytes(8).toString("hex")}`);
|
||||
const fileB = path.join(tempDir, `agy-diff-b-${crypto.randomBytes(8).toString("hex")}`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fileA, contentA, "utf8");
|
||||
fs.writeFileSync(fileB, contentB, "utf8");
|
||||
|
||||
const res = spawnSync("diff", [
|
||||
"-u",
|
||||
"--label",
|
||||
labelA,
|
||||
"--label",
|
||||
labelB,
|
||||
fileA,
|
||||
fileB
|
||||
], { encoding: "utf8" });
|
||||
|
||||
if (res.status === 1 || res.status === 0) {
|
||||
return res.stdout || "";
|
||||
}
|
||||
throw new VcsError(`diff command failed with status ${res.status}: ${res.stderr}`);
|
||||
} finally {
|
||||
try { if (fs.existsSync(fileA)) fs.unlinkSync(fileA); } catch {}
|
||||
try { if (fs.existsSync(fileB)) fs.unlinkSync(fileB); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export class NoneProvider implements VcsProvider {
|
||||
readonly kind: VcsKind = "none";
|
||||
|
||||
repoRoot(cwd: string = process.cwd()): string {
|
||||
return cwd;
|
||||
}
|
||||
|
||||
captureBaseline(repoRoot: string, runDir?: string): VcsBaseline {
|
||||
if (!runDir) {
|
||||
throw new VcsError("None VCS provider requires a run directory to capture baseline snapshot.");
|
||||
}
|
||||
|
||||
const ignores: { gitignore?: string; workflowignore?: string } = {};
|
||||
const gitignorePath = path.join(repoRoot, ".gitignore");
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
try {
|
||||
ignores.gitignore = fs.readFileSync(gitignorePath, "utf8");
|
||||
} catch {}
|
||||
}
|
||||
const workflowignorePath = path.join(repoRoot, ".agent-workflowignore");
|
||||
if (fs.existsSync(workflowignorePath)) {
|
||||
try {
|
||||
ignores.workflowignore = fs.readFileSync(workflowignorePath, "utf8");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const snapshot = generateSnapshot(repoRoot, ignores);
|
||||
snapshot.ignores = ignores;
|
||||
|
||||
const snapshotPath = path.join(runDir, "baseline-snapshot.json");
|
||||
try {
|
||||
const bytes = JSON.stringify(snapshot, null, 2);
|
||||
fs.writeFileSync(snapshotPath, bytes, "utf8");
|
||||
const hash = crypto.createHash("sha256").update(Buffer.from(bytes, "utf8")).digest("hex");
|
||||
return {
|
||||
kind: "none",
|
||||
snapshotFile: "baseline-snapshot.json",
|
||||
snapshotHash: hash,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new VcsError(`Failed to save baseline snapshot: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
isClean(_repoRoot: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
diffFromBaseline(repoRoot: string, baseline: VcsBaseline, runDir?: string): string {
|
||||
if (baseline.kind !== "none") {
|
||||
throw new VcsError("None provider requires none baseline.");
|
||||
}
|
||||
const baselineSnapshot = readBaselineSnapshot(baseline, runDir);
|
||||
const current = generateSnapshot(repoRoot, baselineSnapshot.ignores);
|
||||
|
||||
let combinedDiff = "";
|
||||
|
||||
const allPaths = new Set([
|
||||
...Object.keys(baselineSnapshot.files),
|
||||
...Object.keys(current.files)
|
||||
]);
|
||||
const sortedPaths = Array.from(allPaths).sort();
|
||||
|
||||
for (const posixPath of sortedPaths) {
|
||||
const baseFile = baselineSnapshot.files[posixPath];
|
||||
const currFile = current.files[posixPath];
|
||||
|
||||
if (!baseFile && currFile) {
|
||||
// File added
|
||||
if (currFile.type === "text") {
|
||||
if (currFile.content === "") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `new file mode 100644\n`;
|
||||
combinedDiff += `--- /dev/null\n`;
|
||||
combinedDiff += `+++ b/${posixPath}\n`;
|
||||
} else {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/dev/null`, `b/${posixPath}`, "", currFile.content);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (currFile.type === "binary") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `Binary files /dev/null and b/${posixPath} differ\n`;
|
||||
} else if (currFile.type === "symlink") {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += `new file mode 120000\n`;
|
||||
fileDiff += diffStrings(`/dev/null`, `b/${posixPath}`, "", `Symbolic link to ${currFile.target}\n`);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (baseFile && !currFile) {
|
||||
// File deleted
|
||||
if (baseFile.type === "text") {
|
||||
if (baseFile.content === "") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `deleted file mode 100644\n`;
|
||||
combinedDiff += `--- a/${posixPath}\n`;
|
||||
combinedDiff += `+++ /dev/null\n`;
|
||||
} else {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `b/dev/null`, baseFile.content, "");
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (baseFile.type === "binary") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `Binary files a/${posixPath} and /dev/null differ\n`;
|
||||
} else if (baseFile.type === "symlink") {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += `deleted file mode 120000\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `/dev/null`, `Symbolic link to ${baseFile.target}\n`, "");
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (baseFile && currFile) {
|
||||
// File exists in both
|
||||
if (baseFile.type !== currFile.type) {
|
||||
// Type changed, treat as deleted old and added new
|
||||
if (baseFile.type === "text") {
|
||||
if (baseFile.content === "") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `deleted file mode 100644\n`;
|
||||
combinedDiff += `--- a/${posixPath}\n`;
|
||||
combinedDiff += `+++ /dev/null\n`;
|
||||
} else {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `b/dev/null`, baseFile.content, "");
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (baseFile.type === "binary") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `Binary files a/${posixPath} and /dev/null differ\n`;
|
||||
} else if (baseFile.type === "symlink") {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += `deleted file mode 120000\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `/dev/null`, `Symbolic link to ${baseFile.target}\n`, "");
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
|
||||
if (currFile.type === "text") {
|
||||
if (currFile.content === "") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `new file mode 100644\n`;
|
||||
combinedDiff += `--- /dev/null\n`;
|
||||
combinedDiff += `+++ b/${posixPath}\n`;
|
||||
} else {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/dev/null`, `b/${posixPath}`, "", currFile.content);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (currFile.type === "binary") {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `Binary files /dev/null and b/${posixPath} differ\n`;
|
||||
} else if (currFile.type === "symlink") {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += `new file mode 120000\n`;
|
||||
fileDiff += diffStrings(`/dev/null`, `b/${posixPath}`, "", `Symbolic link to ${currFile.target}\n`);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else {
|
||||
// Same type
|
||||
if (baseFile.type === "text" && currFile.type === "text") {
|
||||
if (baseFile.hash !== currFile.hash) {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `b/${posixPath}`, baseFile.content, currFile.content);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
} else if (baseFile.type === "binary" && currFile.type === "binary") {
|
||||
if (baseFile.hash !== currFile.hash) {
|
||||
combinedDiff += `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
combinedDiff += `Binary files a/${posixPath} and b/${posixPath} differ\n`;
|
||||
}
|
||||
} else if (baseFile.type === "symlink" && currFile.type === "symlink") {
|
||||
if (baseFile.target !== currFile.target) {
|
||||
let fileDiff = `diff --git a/${posixPath} b/${posixPath}\n`;
|
||||
fileDiff += diffStrings(`a/${posixPath}`, `b/${posixPath}`, `Symbolic link to ${baseFile.target}\n`, `Symbolic link to ${currFile.target}\n`);
|
||||
combinedDiff += fileDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combinedDiff.trim();
|
||||
}
|
||||
|
||||
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[], runDir?: string): string[] {
|
||||
if (baseline.kind !== "none") {
|
||||
throw new VcsError("None provider requires none baseline.");
|
||||
}
|
||||
const baselineSnapshot = readBaselineSnapshot(baseline, runDir);
|
||||
const changedFiles = getChangedFiles(repoRoot, baselineSnapshot);
|
||||
|
||||
const outOfScope: string[] = [];
|
||||
for (const file of changedFiles) {
|
||||
const inScope = scope.some((s) => {
|
||||
const normalized = s.replace(/\/+$/, "");
|
||||
return file === normalized || file.startsWith(normalized + "/");
|
||||
});
|
||||
if (!inScope) outOfScope.push(file);
|
||||
}
|
||||
return outOfScope;
|
||||
}
|
||||
|
||||
validateBaseline(repoRoot: string, baseline: VcsBaseline, runDir?: string): void {
|
||||
if (baseline.kind !== "none") {
|
||||
throw new VcsError("None provider requires none baseline.");
|
||||
}
|
||||
readBaselineSnapshot(baseline, runDir);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VCS Detection & Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -768,9 +1343,7 @@ export function detectVcs(cwd: string): VcsKind {
|
||||
if (hasGit) return "git";
|
||||
if (hasSvn) return "svn";
|
||||
|
||||
throw new VcsError(
|
||||
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
|
||||
);
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Find VCS candidates without throwing on ambiguity. */
|
||||
@@ -802,6 +1375,8 @@ export function createVcsProvider(kind: VcsKind): VcsProvider {
|
||||
return new GitProvider();
|
||||
case "svn":
|
||||
return new SvnProvider();
|
||||
case "none":
|
||||
return new NoneProvider();
|
||||
default: {
|
||||
const _exhaustive: never = kind;
|
||||
throw new VcsError(`Unknown VCS kind: ${_exhaustive}`);
|
||||
|
||||
@@ -91,9 +91,9 @@ function parsePositiveInt(flag: string, value: string): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
const WORKFLOW_USAGE = `
|
||||
export const WORKFLOW_USAGE = `
|
||||
Usage:
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn>] [--max-cycles <n>]
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn|none>] [--max-cycles <n>]
|
||||
agent-workflow review --run <run-id>
|
||||
agent-workflow retry-review --run <run-id>
|
||||
agent-workflow retry-execute --run <run-id>
|
||||
@@ -107,6 +107,7 @@ Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 fo
|
||||
|
||||
Examples:
|
||||
agent-workflow start --plan plan.json --executor reasonix
|
||||
agent-workflow start --plan plan.json --executor reasonix --vcs none
|
||||
agent-workflow start --plan plan.json --executor reasonix --vcs svn
|
||||
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
|
||||
agent-workflow review --run 2026-01-01T00-00-00_01_abc123
|
||||
|
||||
+135
-89
@@ -41,6 +41,7 @@ import {
|
||||
releaseLock,
|
||||
runDir as makeRunDir,
|
||||
writeState,
|
||||
workflowsRoot,
|
||||
} from "./workflow-state.js";
|
||||
import {
|
||||
createVcsProvider,
|
||||
@@ -245,18 +246,54 @@ export interface StartWorkflowOpts {
|
||||
maxCycles?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** Resolve VCS selection from candidates and configs - exported for testing */
|
||||
export async function resolveVcsSelection(
|
||||
cwd: string,
|
||||
explicitVcs?: VcsKind
|
||||
): Promise<{ vcsKind: VcsKind; repoRoot: string }> {
|
||||
): Promise<{ vcsKind: VcsKind; repoRoot: string; configSourceRoot?: string }> {
|
||||
// Discover candidates
|
||||
const { findVcsCandidates } = await import("./vcs-provider.js");
|
||||
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
|
||||
|
||||
// Load config from candidate roots plus cwd to find defaultVcs
|
||||
const configs = new Map<string, { vcs?: VcsKind | "auto"; root: string }>();
|
||||
const rootsToTry = new Set<string>();
|
||||
if (candidates.git) rootsToTry.add(candidates.git);
|
||||
if (candidates.svn) rootsToTry.add(candidates.svn);
|
||||
rootsToTry.add(cwd); // always try cwd as well
|
||||
|
||||
for (const root of rootsToTry) {
|
||||
try {
|
||||
const config = resolveWorkflowConfig(root, {});
|
||||
configs.set(root, { vcs: config.vcs, root });
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowConfigError) {
|
||||
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${err.message}`);
|
||||
}
|
||||
const errCode = (err as any)?.code;
|
||||
if (errCode === "ENOENT") {
|
||||
configs.set(root, { root });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If explicit VCS provided, use it directly
|
||||
if (explicitVcs) {
|
||||
const provider = createVcsProvider(explicitVcs);
|
||||
try {
|
||||
const repoRoot = provider.repoRoot(cwd);
|
||||
return { vcsKind: explicitVcs, repoRoot };
|
||||
let configSourceRoot = repoRoot;
|
||||
if (explicitVcs === "none") {
|
||||
for (const root of rootsToTry) {
|
||||
if (fs.existsSync(path.join(root, "agent-workflow.json"))) {
|
||||
configSourceRoot = root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { vcsKind: explicitVcs, repoRoot, configSourceRoot };
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
@@ -265,47 +302,7 @@ export async function resolveVcsSelection(
|
||||
}
|
||||
}
|
||||
|
||||
// Discover candidates
|
||||
const { findVcsCandidates } = await import("./vcs-provider.js");
|
||||
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
|
||||
|
||||
if (!candidates.git && !candidates.svn) {
|
||||
throw new WorkflowEngineError(
|
||||
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
|
||||
);
|
||||
}
|
||||
|
||||
// Load config from every distinct candidate root
|
||||
const configs = new Map<string, { vcs?: VcsKind; root: string }>();
|
||||
|
||||
for (const [, root] of Object.entries(candidates) as Array<[string, string | undefined]>) {
|
||||
if (!root) continue;
|
||||
|
||||
// Skip if we already loaded config from this root
|
||||
if (configs.has(root)) continue;
|
||||
|
||||
try {
|
||||
const config = resolveWorkflowConfig(root, {});
|
||||
configs.set(root, { vcs: config.vcs, root });
|
||||
} catch (err) {
|
||||
// Re-throw validation/parse errors (WorkflowConfigError) — these are real problems
|
||||
if (err instanceof WorkflowConfigError) {
|
||||
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`);
|
||||
}
|
||||
// Re-throw file access errors unless the file simply doesn't exist (ENOENT).
|
||||
// Permission denied, I/O errors, generic Error/TypeError, etc. must surface rather than be silently ignored.
|
||||
const errCode = (err as any)?.code;
|
||||
if (errCode === "ENOENT") {
|
||||
// Config file doesn't exist — that's ok, treat as no project config
|
||||
configs.set(root, { root });
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Extract project defaultVcs settings
|
||||
// Extract project defaultVcs settings from configs
|
||||
const projectDefaults = Array.from(configs.values())
|
||||
.map(c => c.vcs)
|
||||
.filter((v): v is VcsKind => v !== undefined);
|
||||
@@ -318,15 +315,25 @@ export async function resolveVcsSelection(
|
||||
);
|
||||
}
|
||||
|
||||
// Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error
|
||||
const projectDefault = projectDefaults[0];
|
||||
|
||||
if (projectDefault === "none") {
|
||||
let configSourceRoot = cwd;
|
||||
for (const c of configs.values()) {
|
||||
if (c.vcs === "none") {
|
||||
configSourceRoot = c.root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { vcsKind: "none", repoRoot: cwd, configSourceRoot };
|
||||
}
|
||||
|
||||
if (candidates.git && candidates.svn) {
|
||||
// Ambiguous: need explicit selection
|
||||
if (projectDefault === "git") {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git };
|
||||
} else if (projectDefault === "svn") {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn };
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn };
|
||||
} else {
|
||||
throw new WorkflowEngineError(
|
||||
"Ambiguous VCS: both Git and SVN detected. " +
|
||||
@@ -334,9 +341,23 @@ export async function resolveVcsSelection(
|
||||
);
|
||||
}
|
||||
} else if (candidates.git) {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
if (projectDefault === "svn") {
|
||||
throw new WorkflowEngineError(`Configured VCS is "svn" but no SVN working copy was detected.`);
|
||||
}
|
||||
return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git };
|
||||
} else if (candidates.svn) {
|
||||
if (projectDefault === "git") {
|
||||
throw new WorkflowEngineError(`Configured VCS is "git" but no Git repository was detected.`);
|
||||
}
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn };
|
||||
} else {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn! };
|
||||
// Neither detected
|
||||
if (projectDefault === "git" || projectDefault === "svn") {
|
||||
throw new WorkflowEngineError(
|
||||
`Configured VCS is "${projectDefault}" but no such VCS repository was detected.`
|
||||
);
|
||||
}
|
||||
return { vcsKind: "none", repoRoot: cwd, configSourceRoot: cwd };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,10 +385,25 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
const typedPlan = plan as WorkflowPlanV1;
|
||||
|
||||
// 2. Resolve VCS selection using deterministic helper
|
||||
const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
|
||||
const { vcsKind, repoRoot, configSourceRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
|
||||
|
||||
// 3. Resolve full config from final repoRoot
|
||||
const config = resolveWorkflowConfig(repoRoot, {
|
||||
// Reject AGENT_WORKFLOW_DIR/workflow state root that is equal to or nested under target repoRoot in none mode
|
||||
if (vcsKind === "none") {
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
const absStateRoot = path.resolve(workflowsRoot());
|
||||
const relative = path.relative(absRepoRoot, absStateRoot);
|
||||
const isNestedOrEqual = !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
if (isNestedOrEqual) {
|
||||
throw new WorkflowEngineError(
|
||||
`Workflow state directory (${absStateRoot}) cannot be equal to or nested under the repository root (${absRepoRoot}) in none VCS mode. ` +
|
||||
`Please set the AGENT_WORKFLOW_DIR environment variable to an external location.`,
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve full config from configSourceRoot or final repoRoot
|
||||
const config = resolveWorkflowConfig(configSourceRoot ?? repoRoot, {
|
||||
...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}),
|
||||
...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}),
|
||||
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
|
||||
@@ -378,35 +414,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
// 4. Probe executor
|
||||
await probeExecutor(config.executor, config.executorConfig);
|
||||
|
||||
// 5. Require clean working tree
|
||||
let isClean: boolean;
|
||||
try {
|
||||
isClean = vcsProvider.isClean(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
// 5. Require clean working tree (skipped in none mode)
|
||||
if (vcsKind !== "none") {
|
||||
let isClean: boolean;
|
||||
try {
|
||||
isClean = vcsProvider.isClean(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
if (!isClean) {
|
||||
throw new WorkflowEngineError(
|
||||
"Working tree has uncommitted changes. Commit or stash them before starting a workflow.",
|
||||
4,
|
||||
);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
if (!isClean) {
|
||||
throw new WorkflowEngineError(
|
||||
"Working tree has uncommitted changes. Commit or stash them before starting a workflow.",
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Capture baseline
|
||||
let vcsBaseline: VcsBaseline;
|
||||
try {
|
||||
vcsBaseline = vcsProvider.captureBaseline(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
// 7. Acquire lock (one active workflow per repo)
|
||||
// 6. Acquire lock (one active workflow per repo)
|
||||
const runId = generateRunId();
|
||||
try {
|
||||
acquireLock(repoRoot, runId);
|
||||
@@ -420,6 +447,20 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
const dir = makeRunDir(repoRoot, runId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// 7. Capture baseline
|
||||
let vcsBaseline: VcsBaseline;
|
||||
try {
|
||||
vcsBaseline = vcsProvider.captureBaseline(repoRoot, dir);
|
||||
} catch (err) {
|
||||
try {
|
||||
releaseLock(repoRoot, runId);
|
||||
} catch {}
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
const state = initWorkflowState({
|
||||
runId,
|
||||
repoRoot,
|
||||
@@ -433,6 +474,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
state.executorConfig = config.executorConfig;
|
||||
state.timeoutSeconds = config.timeoutSeconds;
|
||||
state.enginePid = process.pid;
|
||||
state.status = "executing";
|
||||
writeState(dir, state);
|
||||
appendEvent(dir, {
|
||||
kind: "workflow_started",
|
||||
@@ -449,6 +491,8 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
||||
} else if (vcsBaseline.kind === "svn") {
|
||||
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`);
|
||||
} else if (vcsBaseline.kind === "none") {
|
||||
process.stdout.write(` baseline: snapshot\n`);
|
||||
}
|
||||
process.stdout.write(` run dir: ${dir}\n\n`);
|
||||
|
||||
@@ -484,6 +528,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
config: config.executorConfig,
|
||||
timeoutSeconds: state.timeoutSeconds,
|
||||
signal: ac.signal,
|
||||
vcsKind,
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
@@ -568,7 +613,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
// 1. Generate diff from baseline
|
||||
let diff: string;
|
||||
try {
|
||||
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline);
|
||||
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`);
|
||||
@@ -582,7 +627,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
|
||||
// 2. Check for baseline drift
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(
|
||||
@@ -599,7 +644,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
// 3. Check out-of-scope files
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
|
||||
@@ -922,6 +967,7 @@ async function resumeExecutorCycle(
|
||||
config: state.executorConfig ?? {},
|
||||
timeoutSeconds: state.timeoutSeconds,
|
||||
signal: ac.signal,
|
||||
vcsKind: state.vcsKind || "git",
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
@@ -996,7 +1042,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return err.message;
|
||||
@@ -1007,7 +1053,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
|
||||
// 3. All files must be in scope
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return `Scope check failed: ${err.message}`;
|
||||
@@ -1129,7 +1175,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
@@ -1139,7 +1185,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
@@ -1237,7 +1283,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
@@ -1247,7 +1293,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
@@ -1394,7 +1440,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
|
||||
@@ -1403,7 +1449,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
|
||||
|
||||
let outOfScope: string[] = [];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: scope check failed: ${message}`);
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
ExecutorStartOpts,
|
||||
ProgressSidecar,
|
||||
ReasonixSessionHandle,
|
||||
VcsKind,
|
||||
} from "./workflow-types.js";
|
||||
import {
|
||||
nowIso,
|
||||
@@ -37,7 +38,17 @@ import {
|
||||
// Safety prompt footer (appended to every executor instruction)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFETY_CONSTRAINTS = `
|
||||
function getSafetyConstraints(vcsKind?: VcsKind): string {
|
||||
let vcsInspectCmd = "";
|
||||
if (vcsKind === "svn") {
|
||||
vcsInspectCmd = "Before exiting, inspect svn status and leave no artifacts outside the allowed scope.";
|
||||
} else if (vcsKind === "none") {
|
||||
vcsInspectCmd = "Before exiting, confirm that no temporary files or paths outside the allowed scope remain.";
|
||||
} else {
|
||||
vcsInspectCmd = "Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.";
|
||||
}
|
||||
|
||||
return `
|
||||
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
|
||||
|
||||
- DO NOT run git commit, git push, or git reset under any circumstances.
|
||||
@@ -49,10 +60,11 @@ const SAFETY_CONSTRAINTS = `
|
||||
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
|
||||
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
|
||||
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
|
||||
- Before exiting, inspect git status --porcelain --untracked-files=all (or svn status) and leave no artifacts outside the allowed scope.
|
||||
- ${vcsInspectCmd}
|
||||
- You may read, edit, and test code, but never commit or push changes.
|
||||
- If you encounter an error you cannot resolve, stop and report it clearly.
|
||||
`.trimStart();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress sidecar helpers
|
||||
@@ -246,7 +258,7 @@ ${criteriaList}
|
||||
After implementation, ensure these commands pass:
|
||||
${cmdsText}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
${getSafetyConstraints(opts.vcsKind)}`;
|
||||
}
|
||||
|
||||
function buildFixPrompt(opts: ExecutorResumeOpts): string {
|
||||
@@ -269,7 +281,7 @@ ${plan.scope.map((s) => ` - ${s}`).join("\n")}
|
||||
After scope cleanup, run:
|
||||
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
${getSafetyConstraints(opts.vcsKind)}`;
|
||||
}
|
||||
|
||||
return `# Fix Request: ${plan.title}
|
||||
@@ -288,7 +300,7 @@ ${plan.scope.map((s) => ` - ${s}`).join("\n")}
|
||||
After applying fixes, run:
|
||||
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
|
||||
|
||||
${SAFETY_CONSTRAINTS}`;
|
||||
${getSafetyConstraints(opts.vcsKind)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
-2
@@ -19,7 +19,7 @@ export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
|
||||
// VCS abstraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VCS_KINDS = ["git", "svn"] as const;
|
||||
export const VCS_KINDS = ["git", "svn", "none"] as const;
|
||||
export type VcsKind = (typeof VCS_KINDS)[number];
|
||||
|
||||
/** Structured VCS baseline (replaces single baselineHead string). */
|
||||
@@ -38,7 +38,13 @@ export interface VcsBaselineSvn {
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export type VcsBaseline = VcsBaselineGit | VcsBaselineSvn;
|
||||
export interface VcsBaselineNone {
|
||||
kind: "none";
|
||||
snapshotFile: string;
|
||||
snapshotHash: string;
|
||||
}
|
||||
|
||||
export type VcsBaseline = VcsBaselineGit | VcsBaselineSvn | VcsBaselineNone;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress sidecar — lightweight real-time observability
|
||||
@@ -428,6 +434,7 @@ export interface ExecutorStartOpts {
|
||||
/** Host timeout, forwarded to executors that enforce their own deadline. */
|
||||
timeoutSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
vcsKind?: VcsKind;
|
||||
}
|
||||
|
||||
export interface ExecutorResumeOpts {
|
||||
@@ -444,4 +451,5 @@ export interface ExecutorResumeOpts {
|
||||
/** Host timeout, forwarded to executors that enforce their own deadline. */
|
||||
timeoutSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
vcsKind?: VcsKind;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user