feat: add snapshot-based none VCS mode

This commit is contained in:
liujing
2026-07-17 11:50:53 +08:00
parent 758c41a953
commit b756a10c35
11 changed files with 1336 additions and 121 deletions
+13 -4
View File
@@ -77,12 +77,13 @@ agent-workflow decide --run <run-id> --input decision.json
Start a new workflow run. Requires a clean working tree. Start a new workflow run. Requires a clean working tree.
```bash ```bash
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--vcs <git|svn>] [--max-cycles <n>] agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--vcs <git|svn|none>] [--max-cycles <n>]
``` ```
- `--vcs` - Explicitly specify VCS kind (`git` or `svn`). If omitted, auto-detects from the working directory. - `--vcs` - Explicitly specify VCS kind (`git`, `svn`, or `none`). If omitted, auto-detects from the working directory. If neither Git nor SVN is detected, it automatically falls back to `none` (snapshot mode).
- Git and SVN working copies are automatically detected when `--vcs` is not provided. - Git and SVN working copies are automatically detected when `--vcs` is not provided.
- SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts). - SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts).
- None (snapshot) mode skips clean checks, capturing the directory state of the working directory and storing it in the run directory.
### `agent-workflow review` ### `agent-workflow review`
@@ -201,7 +202,7 @@ Create `agent-workflow.json` in your repository root:
} }
``` ```
- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, or `"svn"`. Default is `"auto"`. - `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, `"svn"`, or `"none"`. Default is `"auto"`.
## Environment Variables ## Environment Variables
@@ -239,9 +240,17 @@ SVN working copies are supported with strict validation:
- **Rejects**: Switched paths, externals, conflicts, incomplete states, locked files - **Rejects**: Switched paths, externals, conflicts, incomplete states, locked files
- **Baseline**: Repository URL, UUID, and working revision - **Baseline**: Repository URL, UUID, and working revision
- **Diff**: Tracks modifications, additions (including unversioned), deletions, and property changes - **Diff**: Tracks modifications, additions (including unversioned), deletions, and property changes
- Use `svn update` to synchronize revisions before starting a workflow
- The workflow never runs `svn commit`, `svn switch`, or `svn update` - The workflow never runs `svn commit`, `svn switch`, or `svn update`
### None (Snapshot Mode)
Used for directories without any version control system.
- **Trigger**: Runs automatically when no Git or SVN repository is found, or when forced using `--vcs none` (or config `defaultVcs: "none"`).
- **Clean Checks**: Skips standard VCS cleanliness checks.
- **Baseline**: Captures and writes metadata, file paths, size/content hashes, text contents, and symlink targets into `baseline-snapshot.json` in the workflow run directory.
- **Diff**: Generates clean unified diffs by comparing active file systems with the captured baseline.
- **Ignores**: Automatically honors POSIX relative rules in `.gitignore` and `.agent-workflowignore`. Directories like `.git` and `.svn` are always ignored. Only files outside the ignore boundaries are checked against scope gates.
- **Safety constraints**: Dynamic executor safety prompts are adapted so that executors do not attempt to run `git status` or `svn status`.
## Convergence Limits ## Convergence Limits
The workflow stops with `needs_human` when: The workflow stops with `needs_human` when:
+11 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-xml-parser": "^5.10.1" "fast-xml-parser": "^5.10.1",
"ignore": "^7.0.6"
}, },
"bin": { "bin": {
"agent-workflow": "bin/agent-workflow.js" "agent-workflow": "bin/agent-workflow.js"
@@ -1185,6 +1186,15 @@
"url": "https://github.com/sponsors/typicode" "url": "https://github.com/sponsors/typicode"
} }
}, },
"node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/is-unsafe": { "node_modules/is-unsafe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
+2 -1
View File
@@ -53,6 +53,7 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"fast-xml-parser": "^5.10.1" "fast-xml-parser": "^5.10.1",
"ignore": "^7.0.6"
} }
} }
+10 -2
View File
@@ -40,11 +40,12 @@ $agent-workflow-host 日志 <run-id> [--follow]
## Start A Confirmed Plan ## Start A Confirmed Plan
1. Resolve the target VCS root (Git or SVN) and inspect the repository before execution. 1. Resolve the target VCS root (Git, SVN, or None/snapshot mode) and inspect the repository/directory before execution.
2. Run these preflight checks: 2. Run these preflight checks:
- `command -v agent-workflow` - `command -v agent-workflow`
- For Git: `git status --porcelain` must be empty. - For Git: `git status --porcelain` must be empty.
- For SVN: `svn status` must show a clean working copy with no mixed revisions. - For SVN: `svn status` must show a clean working copy with no mixed revisions.
- For None (snapshot mode): No clean checks are required.
- Probe the selected executor with `<executor> --version`. - Probe the selected executor with `<executor> --version`.
- Confirm no unresolved active workflow prevents a new run. - Confirm no unresolved active workflow prevents a new run.
3. Do not install tools, commit, or stash automatically. Report the exact missing prerequisite and stop. 3. Do not install tools, commit, or stash automatically. Report the exact missing prerequisite and stop.
@@ -66,7 +67,7 @@ $agent-workflow-host 日志 <run-id> [--follow]
7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently: 7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently:
```bash ```bash
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> [--vcs <git|svn>] agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> [--vcs <git|svn|none>]
``` ```
After starting, announce: After starting, announce:
@@ -181,6 +182,13 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
- Reject runs with switched paths, externals, conflicts, or incomplete states - Reject runs with switched paths, externals, conflicts, or incomplete states
- If mixed revisions detected: stop and instruct user to run `svn update` - If mixed revisions detected: stop and instruct user to run `svn update`
### None Workflows (Snapshot Mode)
- Automatically falls back to `none` if no Git or SVN repository is found.
- No repository cleanliness checks are required.
- Baseline is stored as a file system snapshot in the workflow run directory (`baseline-snapshot.json`).
- Ignores `.gitignore` and `.agent-workflowignore` directories/files. `.git` and `.svn` are always excluded. Ignored files are not protected by frozen scope gates.
- The executor safety prompt dynamically adapts to avoid running `git status` or `svn status`.
## Final Report ## Final Report
Report: Report:
+2
View File
@@ -147,6 +147,7 @@ export type {
VcsBaseline, VcsBaseline,
VcsBaselineGit, VcsBaselineGit,
VcsBaselineSvn, VcsBaselineSvn,
VcsBaselineNone,
ExtensionRecord, ExtensionRecord,
} from "./workflow-types.js"; } from "./workflow-types.js";
@@ -175,6 +176,7 @@ export {
VcsError, VcsError,
GitProvider, GitProvider,
SvnProvider, SvnProvider,
NoneProvider,
detectVcs, detectVcs,
createVcsProvider, createVcsProvider,
extractBaselineHead, extractBaselineHead,
+590 -15
View File
@@ -7,9 +7,12 @@
*/ */
import { execFileSync, spawnSync } from "node:child_process"; import { execFileSync, spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { XMLParser } from "fast-xml-parser"; import { XMLParser } from "fast-xml-parser";
import ignore from "ignore";
import type { VcsBaseline, VcsKind } from "./workflow-types.js"; import type { VcsBaseline, VcsKind } from "./workflow-types.js";
export class VcsError extends Error { export class VcsError extends Error {
@@ -30,19 +33,19 @@ export interface VcsProvider {
repoRoot(cwd: string): string; repoRoot(cwd: string): string;
/** Capture the current baseline state. */ /** 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). */ /** Check if the working copy is clean (no uncommitted changes). */
isClean(repoRoot: string): boolean; isClean(repoRoot: string): boolean;
/** Generate a diff from the baseline to the current working tree. */ /** 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. */ /** 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). */ /** 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 { try {
const head = execFileSync("git", ["rev-parse", "HEAD"], { const head = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot, 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") { if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline."); 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") { if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline."); throw new VcsError("Git provider requires git baseline.");
} }
@@ -168,7 +171,7 @@ export class GitProvider implements VcsProvider {
return outOfScope; return outOfScope;
} }
validateBaseline(repoRoot: string, baseline: VcsBaseline): void { validateBaseline(repoRoot: string, baseline: VcsBaseline, _runDir?: string): void {
if (baseline.kind !== "git") { if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline."); 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 // Strict validation before capturing baseline
this.validateStrictWorkingCopy(repoRoot); 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") { if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline."); throw new VcsError("SVN provider requires svn baseline.");
} }
@@ -391,7 +394,7 @@ export class SvnProvider implements VcsProvider {
return normalized; return normalized;
} }
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] { checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[], _runDir?: string): string[] {
if (baseline.kind !== "svn") { if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline."); 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") { if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline."); throw new VcsError("SVN provider requires svn baseline.");
} }
@@ -728,6 +731,578 @@ interface SvnStatusEntry {
locked: boolean; 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 // VCS Detection & Factory
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -768,9 +1343,7 @@ export function detectVcs(cwd: string): VcsKind {
if (hasGit) return "git"; if (hasGit) return "git";
if (hasSvn) return "svn"; if (hasSvn) return "svn";
throw new VcsError( return "none";
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
);
} }
/** Find VCS candidates without throwing on ambiguity. */ /** Find VCS candidates without throwing on ambiguity. */
@@ -802,6 +1375,8 @@ export function createVcsProvider(kind: VcsKind): VcsProvider {
return new GitProvider(); return new GitProvider();
case "svn": case "svn":
return new SvnProvider(); return new SvnProvider();
case "none":
return new NoneProvider();
default: { default: {
const _exhaustive: never = kind; const _exhaustive: never = kind;
throw new VcsError(`Unknown VCS kind: ${_exhaustive}`); throw new VcsError(`Unknown VCS kind: ${_exhaustive}`);
+3 -2
View File
@@ -91,9 +91,9 @@ function parsePositiveInt(flag: string, value: string): number {
return n; return n;
} }
const WORKFLOW_USAGE = ` export const WORKFLOW_USAGE = `
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 review --run <run-id>
agent-workflow retry-review --run <run-id> agent-workflow retry-review --run <run-id>
agent-workflow retry-execute --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: Examples:
agent-workflow start --plan plan.json --executor reasonix 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 plan.json --executor reasonix --vcs svn
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
agent-workflow review --run 2026-01-01T00-00-00_01_abc123 agent-workflow review --run 2026-01-01T00-00-00_01_abc123
+135 -89
View File
@@ -41,6 +41,7 @@ import {
releaseLock, releaseLock,
runDir as makeRunDir, runDir as makeRunDir,
writeState, writeState,
workflowsRoot,
} from "./workflow-state.js"; } from "./workflow-state.js";
import { import {
createVcsProvider, createVcsProvider,
@@ -245,18 +246,54 @@ export interface StartWorkflowOpts {
maxCycles?: number; maxCycles?: number;
cwd?: string; cwd?: string;
} }
/** Resolve VCS selection from candidates and configs - exported for testing */ /** Resolve VCS selection from candidates and configs - exported for testing */
export async function resolveVcsSelection( export async function resolveVcsSelection(
cwd: string, cwd: string,
explicitVcs?: VcsKind 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 explicit VCS provided, use it directly
if (explicitVcs) { if (explicitVcs) {
const provider = createVcsProvider(explicitVcs); const provider = createVcsProvider(explicitVcs);
try { try {
const repoRoot = provider.repoRoot(cwd); 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) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message); throw new WorkflowEngineError(err.message);
@@ -265,47 +302,7 @@ export async function resolveVcsSelection(
} }
} }
// Discover candidates // Extract project defaultVcs settings from configs
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
const projectDefaults = Array.from(configs.values()) const projectDefaults = Array.from(configs.values())
.map(c => c.vcs) .map(c => c.vcs)
.filter((v): v is VcsKind => v !== undefined); .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]; 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) { if (candidates.git && candidates.svn) {
// Ambiguous: need explicit selection // Ambiguous: need explicit selection
if (projectDefault === "git") { if (projectDefault === "git") {
return { vcsKind: "git", repoRoot: candidates.git }; return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git };
} else if (projectDefault === "svn") { } else if (projectDefault === "svn") {
return { vcsKind: "svn", repoRoot: candidates.svn }; return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn };
} else { } else {
throw new WorkflowEngineError( throw new WorkflowEngineError(
"Ambiguous VCS: both Git and SVN detected. " + "Ambiguous VCS: both Git and SVN detected. " +
@@ -334,9 +341,23 @@ export async function resolveVcsSelection(
); );
} }
} else if (candidates.git) { } 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 { } 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; const typedPlan = plan as WorkflowPlanV1;
// 2. Resolve VCS selection using deterministic helper // 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 // Reject AGENT_WORKFLOW_DIR/workflow state root that is equal to or nested under target repoRoot in none mode
const config = resolveWorkflowConfig(repoRoot, { 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.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}),
...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}), ...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}),
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}), ...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
@@ -378,35 +414,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
// 4. Probe executor // 4. Probe executor
await probeExecutor(config.executor, config.executorConfig); await probeExecutor(config.executor, config.executorConfig);
// 5. Require clean working tree // 5. Require clean working tree (skipped in none mode)
let isClean: boolean; if (vcsKind !== "none") {
try { let isClean: boolean;
isClean = vcsProvider.isClean(repoRoot); try {
} catch (err) { isClean = vcsProvider.isClean(repoRoot);
if (err instanceof VcsError) { } catch (err) {
throw new WorkflowEngineError(err.message); 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 // 6. Acquire lock (one active workflow per repo)
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)
const runId = generateRunId(); const runId = generateRunId();
try { try {
acquireLock(repoRoot, runId); acquireLock(repoRoot, runId);
@@ -420,6 +447,20 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
const dir = makeRunDir(repoRoot, runId); const dir = makeRunDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true }); 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({ const state = initWorkflowState({
runId, runId,
repoRoot, repoRoot,
@@ -433,6 +474,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
state.executorConfig = config.executorConfig; state.executorConfig = config.executorConfig;
state.timeoutSeconds = config.timeoutSeconds; state.timeoutSeconds = config.timeoutSeconds;
state.enginePid = process.pid; state.enginePid = process.pid;
state.status = "executing";
writeState(dir, state); writeState(dir, state);
appendEvent(dir, { appendEvent(dir, {
kind: "workflow_started", kind: "workflow_started",
@@ -449,6 +491,8 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
process.stdout.write(` baseline: ${vcsBaseline.head}\n`); process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
} else if (vcsBaseline.kind === "svn") { } else if (vcsBaseline.kind === "svn") {
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`); 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`); process.stdout.write(` run dir: ${dir}\n\n`);
@@ -484,6 +528,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
config: config.executorConfig, config: config.executorConfig,
timeoutSeconds: state.timeoutSeconds, timeoutSeconds: state.timeoutSeconds,
signal: ac.signal, signal: ac.signal,
vcsKind,
}); });
} catch (err) { } catch (err) {
cleanup(); cleanup();
@@ -568,7 +613,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
// 1. Generate diff from baseline // 1. Generate diff from baseline
let diff: string; let diff: string;
try { try {
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline); diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`); 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 // 2. Check for baseline drift
try { try {
vcsProvider.validateBaseline(state.repoRoot, baseline); vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
transitionToTerminal( transitionToTerminal(
@@ -599,7 +644,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
// 3. Check out-of-scope files // 3. Check out-of-scope files
let outOfScope: string[]; let outOfScope: string[];
try { try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope); outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`); transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
@@ -922,6 +967,7 @@ async function resumeExecutorCycle(
config: state.executorConfig ?? {}, config: state.executorConfig ?? {},
timeoutSeconds: state.timeoutSeconds, timeoutSeconds: state.timeoutSeconds,
signal: ac.signal, signal: ac.signal,
vcsKind: state.vcsKind || "git",
}); });
} catch (err) { } catch (err) {
cleanup(); cleanup();
@@ -996,7 +1042,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
} }
try { try {
vcsProvider.validateBaseline(state.repoRoot, baseline); vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
return err.message; return err.message;
@@ -1007,7 +1053,7 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
// 3. All files must be in scope // 3. All files must be in scope
let outOfScope: string[]; let outOfScope: string[];
try { try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope); outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
return `Scope check failed: ${err.message}`; return `Scope check failed: ${err.message}`;
@@ -1129,7 +1175,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
} }
try { try {
vcsProvider.validateBaseline(state.repoRoot, baseline); vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4); throw new WorkflowEngineError(err.message, 4);
@@ -1139,7 +1185,7 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
let outOfScope: string[]; let outOfScope: string[];
try { try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope); outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4); throw new WorkflowEngineError(err.message, 4);
@@ -1237,7 +1283,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
} }
try { try {
vcsProvider.validateBaseline(state.repoRoot, baseline); vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4); throw new WorkflowEngineError(err.message, 4);
@@ -1247,7 +1293,7 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
let outOfScope: string[]; let outOfScope: string[];
try { try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope); outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) { } catch (err) {
if (err instanceof VcsError) { if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4); throw new WorkflowEngineError(err.message, 4);
@@ -1394,7 +1440,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
} }
try { try {
vcsProvider.validateBaseline(state.repoRoot, baseline); vcsProvider.validateBaseline(state.repoRoot, baseline, dir);
} catch (err) { } catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message; const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: baseline validation failed: ${message}`); failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
@@ -1403,7 +1449,7 @@ export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
let outOfScope: string[] = []; let outOfScope: string[] = [];
try { try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope); outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope, dir);
} catch (err) { } catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message; const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: scope check failed: ${message}`); failAndRelease(`Cannot extend: scope check failed: ${message}`);
+17 -5
View File
@@ -25,6 +25,7 @@ import type {
ExecutorStartOpts, ExecutorStartOpts,
ProgressSidecar, ProgressSidecar,
ReasonixSessionHandle, ReasonixSessionHandle,
VcsKind,
} from "./workflow-types.js"; } from "./workflow-types.js";
import { import {
nowIso, nowIso,
@@ -37,7 +38,17 @@ import {
// Safety prompt footer (appended to every executor instruction) // 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 ## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances. - 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). - 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. - 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. - 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. - 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. - If you encounter an error you cannot resolve, stop and report it clearly.
`.trimStart(); `.trimStart();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Progress sidecar helpers // Progress sidecar helpers
@@ -246,7 +258,7 @@ ${criteriaList}
After implementation, ensure these commands pass: After implementation, ensure these commands pass:
${cmdsText} ${cmdsText}
${SAFETY_CONSTRAINTS}`; ${getSafetyConstraints(opts.vcsKind)}`;
} }
function buildFixPrompt(opts: ExecutorResumeOpts): string { function buildFixPrompt(opts: ExecutorResumeOpts): string {
@@ -269,7 +281,7 @@ ${plan.scope.map((s) => ` - ${s}`).join("\n")}
After scope cleanup, run: After scope cleanup, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} ${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`; ${getSafetyConstraints(opts.vcsKind)}`;
} }
return `# Fix Request: ${plan.title} return `# Fix Request: ${plan.title}
@@ -288,7 +300,7 @@ ${plan.scope.map((s) => ` - ${s}`).join("\n")}
After applying fixes, run: After applying fixes, run:
${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} ${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")}
${SAFETY_CONSTRAINTS}`; ${getSafetyConstraints(opts.vcsKind)}`;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+543
View File
@@ -0,0 +1,543 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
import {
detectVcs,
NoneProvider,
} from "./vcs-provider.js";
import { resolveVcsSelection, startWorkflow, reviewWorkflow } from "./workflow-engine.js";
import { findRunDir, readState, releaseLock } from "./workflow-state.js";
import type { WorkflowPlanV1 } from "./workflow-types.js";
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-none-test-"));
}
function cleanupDir(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
function makeValidPlan(scope?: string[]): WorkflowPlanV1 {
return {
version: "1",
title: "Test plan",
planMarkdown: "Do something.",
scope: scope ?? ["src/"],
acceptanceCriteria: ["Tests pass"],
verificationCommands: [["echo", "test"]],
};
}
describe("None VCS Provider (Snapshot Mode)", () => {
let tmpDir: string;
let runDir: string;
let stateRoot: string;
let fakeBinDir: string;
let originalWorkflowDir: string | undefined;
beforeEach(() => {
tmpDir = makeTmpDir();
runDir = makeTmpDir();
stateRoot = makeTmpDir(); // Keep stateRoot completely outside target tmpDir
fakeBinDir = path.join(tmpDir, "bin");
fs.mkdirSync(fakeBinDir, { recursive: true });
const fakeBin = path.join(fakeBinDir, "reasonix");
fs.writeFileSync(
fakeBin,
[`#!/bin/bash`, `echo '{"session_id":"test-session"}'`, `exit 0`].join("\n"),
{ mode: 0o755 }
);
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
});
afterEach(() => {
if (originalWorkflowDir === undefined) {
delete process.env.AGENT_WORKFLOW_DIR;
} else {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
}
cleanupDir(tmpDir);
cleanupDir(runDir);
cleanupDir(stateRoot);
});
it("automatically selects none in a non-VCS directory", async () => {
expect(detectVcs(tmpDir)).toBe("none");
const selection = await resolveVcsSelection(tmpDir);
expect(selection.vcsKind).toBe("none");
expect(selection.repoRoot).toBe(tmpDir);
});
it("fails on explicit --vcs git or svn in a non-VCS directory, but --vcs none works", async () => {
await expect(resolveVcsSelection(tmpDir, "git")).rejects.toThrow();
await expect(resolveVcsSelection(tmpDir, "svn")).rejects.toThrow();
const selection = await resolveVcsSelection(tmpDir, "none");
expect(selection.vcsKind).toBe("none");
expect(selection.repoRoot).toBe(tmpDir);
});
it("honors defaultVcs: 'none' project configuration", async () => {
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "none" }), "utf8");
const selection = await resolveVcsSelection(tmpDir);
expect(selection.vcsKind).toBe("none");
});
it("honors defaultVcs: 'git' or 'svn' but fails in non-VCS directories", async () => {
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(configPath, JSON.stringify({ version: "1", defaultVcs: "git" }), "utf8");
await expect(resolveVcsSelection(tmpDir)).rejects.toThrow(/Configured VCS is "git"/);
});
it("records snapshot files and detects additions, modifications, and deletions including empty, unicode, special-character, and binary files", () => {
const provider = new NoneProvider();
// Setup initial files
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8");
// Create binary file
const binaryData = Buffer.from([1, 2, 0, 4, 5]);
fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), binaryData);
// Unicode path
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8");
// Special characters path
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8");
// Capture baseline
const baseline = provider.captureBaseline(tmpDir, runDir);
expect(baseline.kind).toBe("none");
// Check no differences initially
let diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toBe("");
// 1. Text modification/addition/deletion
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/text.txt b/src/text.txt");
expect(diff).toContain("-hello world");
expect(diff).toContain("+hello world modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/text.txt");
// Revert text.txt
fs.writeFileSync(path.join(tmpDir, "src", "text.txt"), "hello world\n", "utf8");
// Add new text file
fs.writeFileSync(path.join(tmpDir, "src", "added.txt"), "new content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/added.txt b/src/added.txt");
expect(diff).toContain("+new content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/added.txt");
// Delete it
fs.unlinkSync(path.join(tmpDir, "src", "added.txt"));
// 2. Empty file modification/addition/deletion
// Add new empty file
fs.writeFileSync(path.join(tmpDir, "src", "new-empty.txt"), "", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/new-empty.txt b/src/new-empty.txt");
expect(diff).toContain("new file mode 100644");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new-empty.txt");
fs.unlinkSync(path.join(tmpDir, "src", "new-empty.txt"));
// Modify existing empty.txt
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "no longer empty\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt");
expect(diff).toContain("+no longer empty");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore
// Delete empty.txt
fs.unlinkSync(path.join(tmpDir, "src", "empty.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/empty.txt b/src/empty.txt");
expect(diff).toContain("deleted file mode 100644");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/empty.txt");
fs.writeFileSync(path.join(tmpDir, "src", "empty.txt"), "", "utf8"); // restore
// 3. Unicode path modification/addition/deletion
// Add new Unicode path
fs.writeFileSync(path.join(tmpDir, "src", "新建文件.txt"), "unicode content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/新建文件.txt b/src/新建文件.txt");
expect(diff).toContain("+unicode content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/新建文件.txt");
fs.unlinkSync(path.join(tmpDir, "src", "新建文件.txt"));
// Modify existing 中文.txt
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt");
expect(diff).toContain("+unicode test modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt");
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore
// Delete 中文.txt
fs.unlinkSync(path.join(tmpDir, "src", "中文.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/中文.txt b/src/中文.txt");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/中文.txt");
fs.writeFileSync(path.join(tmpDir, "src", "中文.txt"), "unicode test\n", "utf8"); // restore
// 4. Special characters path modification/addition/deletion
// Add new special char path
fs.writeFileSync(path.join(tmpDir, "src", "new special#char$path.txt"), "special content\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/new special#char$path.txt b/src/new special#char$path.txt");
expect(diff).toContain("+special content");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/new special#char$path.txt");
fs.unlinkSync(path.join(tmpDir, "src", "new special#char$path.txt"));
// Modify special space & char.txt
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters modified\n", "utf8");
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt");
expect(diff).toContain("+special characters modified");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt");
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore
// Delete special space & char.txt
fs.unlinkSync(path.join(tmpDir, "src", "special space & char.txt"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/special space & char.txt b/src/special space & char.txt");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/special space & char.txt");
fs.writeFileSync(path.join(tmpDir, "src", "special space & char.txt"), "special characters\n", "utf8"); // restore
// 5. Binary file modification/deletion
fs.writeFileSync(path.join(tmpDir, "src", "binary.bin"), Buffer.from([1, 2, 0, 4, 6]));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin");
expect(diff).toContain("Binary files a/src/binary.bin and b/src/binary.bin differ");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin");
fs.unlinkSync(path.join(tmpDir, "src", "binary.bin"));
diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/src/binary.bin b/src/binary.bin");
expect(diff).toContain("Binary files a/src/binary.bin and /dev/null differ");
expect(provider.checkFilesInScope(tmpDir, baseline, ["other/"], runDir)).toContain("src/binary.bin");
});
it("respects ignore patterns from .gitignore and .agent-workflowignore", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "ignored-dir"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content", "utf8");
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "b.txt"), "content", "utf8");
// Add gitignore ignoring ignored-dir/
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n*.log\n", "utf8");
fs.writeFileSync(path.join(tmpDir, "src", "test.log"), "log content", "utf8");
// Add agent-workflowignore with negative rule
fs.writeFileSync(path.join(tmpDir, ".agent-workflowignore"), "!test.log\n", "utf8");
// Let's capture baseline
const baseline = provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
const files = Object.keys(snapshot.files);
// .git and .svn are always excluded
expect(files).not.toContain(".git");
expect(files).not.toContain(".svn");
// ignored-dir/ is ignored by .gitignore
expect(files).not.toContain("ignored-dir/b.txt");
// test.log is ignored by *.log in .gitignore but negated by !test.log in .agent-workflowignore
expect(files).toContain("src/test.log");
});
it("does not follow symbolic links, only records link target", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "target.txt"), "target content\n", "utf8");
// Create a symlink to target.txt
try {
fs.symlinkSync("target.txt", path.join(tmpDir, "src", "link.txt"));
} catch {
// Symlink support might require admin privileges on some systems (e.g. Windows),
// but on Linux sandbox it works.
}
if (fs.existsSync(path.join(tmpDir, "src", "link.txt"))) {
const baseline = provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
expect(snapshot.files["src/link.txt"]).toBeDefined();
expect(snapshot.files["src/link.txt"].type).toBe("symlink");
expect(snapshot.files["src/link.txt"].target).toBe("target.txt");
}
});
it("fails drift check/validation if snapshot is missing or corrupted", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Works originally
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).not.toThrow();
// Missing snapshot file
fs.unlinkSync(path.join(runDir, "baseline-snapshot.json"));
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Baseline snapshot file is missing/);
// Corrupted snapshot file (testing invalid JSON: we must update hash to match corrupted content so hash check passes but parse fails)
const corruptedContent = "{invalid-json}";
const corruptedHash = crypto.createHash("sha256").update(Buffer.from(corruptedContent, "utf8")).digest("hex");
fs.writeFileSync(path.join(runDir, "baseline-snapshot.json"), corruptedContent, "utf8");
const corruptedBaseline = { ...baseline, snapshotHash: corruptedHash };
expect(() => provider.validateBaseline(tmpDir, corruptedBaseline, runDir)).toThrow(/Baseline snapshot is not valid JSON/);
});
it("fails validation/drift check if snapshot is tampered with using different valid-JSON", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Tamper with snapshot by writing a different valid JSON like {files:{}}
const snapshotPath = path.join(runDir, "baseline-snapshot.json");
fs.writeFileSync(snapshotPath, JSON.stringify({ files: {} }), "utf8");
// Must fail due to hash mismatch
expect(() => provider.validateBaseline(tmpDir, baseline, runDir)).toThrow(/Hash mismatch/);
});
it("fails validation/drift check if snapshotFile traverses outside runDir", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
const baseline = provider.captureBaseline(tmpDir, runDir);
// Attempt path traversal using relative parent directory
const traversalBaseline = { ...baseline, snapshotFile: "../outside.json" };
expect(() => provider.validateBaseline(tmpDir, traversalBaseline, runDir)).toThrow(/Access denied/);
// Attempt path traversal using absolute path
const absoluteBaseline = { ...baseline, snapshotFile: "/etc/passwd" };
expect(() => provider.validateBaseline(tmpDir, absoluteBaseline, runDir)).toThrow(/Access denied/);
});
it("proves mutable ignore files cannot hide scope violations after baseline capture", () => {
const provider = new NoneProvider();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "a.txt"), "content\n", "utf8");
// Capture baseline with no ignores initially
const baseline = provider.captureBaseline(tmpDir, runDir);
// Create a file outside the scope and then write a gitignore ignoring it
fs.writeFileSync(path.join(tmpDir, "secret.txt"), "secret\n", "utf8");
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "secret.txt\n", "utf8");
// The scan must reuse the frozen ignore policy (no ignores),
// so secret.txt must still be detected as changed/out-of-scope!
const changed = provider.checkFilesInScope(tmpDir, baseline, ["src/"], runDir);
expect(changed).toContain("secret.txt");
const diff = provider.diffFromBaseline(tmpDir, baseline, runDir);
expect(diff).toContain("diff --git a/secret.txt b/secret.txt");
});
it("proves admin negations stay unconditionally excluded", () => {
const provider = new NoneProvider();
// Create admin directories and files
fs.mkdirSync(path.join(tmpDir, ".git"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, ".git", "config"), "some config\n", "utf8");
fs.mkdirSync(path.join(tmpDir, ".svn"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, ".svn", "entries"), "some entries\n", "utf8");
// Write negations
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "!.git\n!.git/**\n!.svn\n!.svn/**\n", "utf8");
provider.captureBaseline(tmpDir, runDir);
const snapshot = JSON.parse(fs.readFileSync(path.join(runDir, "baseline-snapshot.json"), "utf8"));
const files = Object.keys(snapshot.files);
expect(files).not.toContain(".git/config");
expect(files).not.toContain(".git");
expect(files).not.toContain(".svn/entries");
expect(files).not.toContain(".svn");
});
it("proves CLI help exposes none under --vcs", async () => {
const { WORKFLOW_USAGE } = await import("./workflow-args.js");
expect(WORKFLOW_USAGE).toContain("--vcs <git|svn|none>");
});
it("rejects starting a workflow if AGENT_WORKFLOW_DIR is equal to or nested under repoRoot", async () => {
const nestedStateRoot = path.join(tmpDir, "nested-workflows");
process.env.AGENT_WORKFLOW_DIR = nestedStateRoot;
const planFile = path.join(tmpDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
await expect(startWorkflow({
planInput: planFile,
executor: "reasonix",
cwd: tmpDir,
})).rejects.toThrow(/cannot be equal to or nested under the repository root/);
});
it("preserves project configuration from parent root with defaultVcs:none while keeping repoRoot as cwd", async () => {
const parentDir = path.join(tmpDir, "parent");
const subDir = path.join(parentDir, "sub");
fs.mkdirSync(subDir, { recursive: true });
// Initialize parentDir as a Git repository root so findVcsCandidates resolves it
execFileSync("git", ["init"], { cwd: parentDir, stdio: "ignore" });
// Write config at parent
const configPath = path.join(parentDir, "agent-workflow.json");
fs.writeFileSync(
configPath,
JSON.stringify({
version: "1",
defaultVcs: "none",
maxCycles: 9,
timeoutSeconds: 99,
defaultExecutor: "agy",
executors: {
agy: {
binary: path.join(fakeBinDir, "reasonix")
}
}
}),
"utf8"
);
// Write plan in subDir
const planFile = path.join(subDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
// Start workflow from subDir
const { runId } = await startWorkflow({
planInput: planFile,
cwd: subDir,
});
const wDir = findRunDir(runId, subDir);
expect(wDir).toBeDefined();
const state = readState(wDir!);
expect(state).toBeDefined();
expect(state!.vcsKind).toBe("none");
expect(state!.repoRoot).toBe(subDir); // remains invocation cwd
expect(state!.maxCycles).toBe(9); // preserved from parent config
expect(state!.timeoutSeconds).toBe(99); // preserved from parent config
expect(state!.executor).toBe("agy"); // preserved from parent config
releaseLock(subDir, runId);
// Assert that CLI overrides still take precedence
const { runId: runId2 } = await startWorkflow({
planInput: planFile,
cwd: subDir,
maxCycles: 3,
});
const wDir2 = findRunDir(runId2, subDir);
const state2 = readState(wDir2!);
expect(state2!.maxCycles).toBe(3); // CLI override takes precedence!
releaseLock(subDir, runId2);
});
it("runs a full none-mode workflow: start, modify in-scope, review, detect out-of-scope changes, and block review", async () => {
const fakeBin = path.join(fakeBinDir, "reasonix");
// Setup working files
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(1)\n", "utf8");
// Setup project config
const configPath = path.join(tmpDir, "agent-workflow.json");
fs.writeFileSync(
configPath,
JSON.stringify({
version: "1",
defaultVcs: "none",
executors: {
reasonix: {
binary: fakeBin,
},
},
}),
"utf8"
);
// Setup plan
const planFile = path.join(tmpDir, "plan.json");
const plan = makeValidPlan(["src/"]);
fs.writeFileSync(planFile, JSON.stringify(plan), "utf8");
// Start workflow
const { runId } = await startWorkflow({
planInput: planFile,
executor: "reasonix",
cwd: tmpDir,
});
const wDir = findRunDir(runId, tmpDir);
expect(wDir).toBeDefined();
const state = readState(wDir!);
expect(state).toBeDefined();
expect(state!.vcsKind).toBe("none");
expect(state!.vcsBaseline?.kind).toBe("none");
expect(state!.status).toBe("awaiting_review"); // Assert startWorkflow naturally reaches awaiting_review
// Modify a file in scope
fs.writeFileSync(path.join(tmpDir, "src", "code.txt"), "console.log(2)\n", "utf8");
// reviewWorkflow must succeed (generates diff, validates scope)
await reviewWorkflow({ runId, cwd: tmpDir });
const updatedState = readState(wDir!);
expect(updatedState!.status).toBe("awaiting_host");
// Now modify a file out of scope
fs.writeFileSync(path.join(tmpDir, "outside.txt"), "hack\n", "utf8");
// Reset status back to awaiting_review
updatedState!.status = "awaiting_review";
fs.writeFileSync(path.join(wDir!, "state.json"), JSON.stringify(updatedState, null, 2), "utf8");
// reviewWorkflow must transition run to awaiting_scope_resolution due to out-of-scope files
await reviewWorkflow({ runId, cwd: tmpDir });
const blockedState = readState(wDir!);
expect(blockedState!.status).toBe("awaiting_scope_resolution");
// Clean up locks
releaseLock(tmpDir, runId);
});
});
+10 -2
View File
@@ -19,7 +19,7 @@ export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// VCS abstraction // 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]; export type VcsKind = (typeof VCS_KINDS)[number];
/** Structured VCS baseline (replaces single baselineHead string). */ /** Structured VCS baseline (replaces single baselineHead string). */
@@ -38,7 +38,13 @@ export interface VcsBaselineSvn {
revision: number; 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 // Progress sidecar — lightweight real-time observability
@@ -428,6 +434,7 @@ export interface ExecutorStartOpts {
/** Host timeout, forwarded to executors that enforce their own deadline. */ /** Host timeout, forwarded to executors that enforce their own deadline. */
timeoutSeconds?: number; timeoutSeconds?: number;
signal?: AbortSignal; signal?: AbortSignal;
vcsKind?: VcsKind;
} }
export interface ExecutorResumeOpts { export interface ExecutorResumeOpts {
@@ -444,4 +451,5 @@ export interface ExecutorResumeOpts {
/** Host timeout, forwarded to executors that enforce their own deadline. */ /** Host timeout, forwarded to executors that enforce their own deadline. */
timeoutSeconds?: number; timeoutSeconds?: number;
signal?: AbortSignal; signal?: AbortSignal;
vcsKind?: VcsKind;
} }