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.
```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.
- 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`
@@ -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
@@ -239,9 +240,17 @@ SVN working copies are supported with strict validation:
- **Rejects**: Switched paths, externals, conflicts, incomplete states, locked files
- **Baseline**: Repository URL, UUID, and working revision
- **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`
### 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
The workflow stops with `needs_human` when:
+11 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.10.1"
"fast-xml-parser": "^5.10.1",
"ignore": "^7.0.6"
},
"bin": {
"agent-workflow": "bin/agent-workflow.js"
@@ -1185,6 +1186,15 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
+2 -1
View File
@@ -53,6 +53,7 @@
"node": ">=20"
},
"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
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:
- `command -v agent-workflow`
- For Git: `git status --porcelain` must be empty.
- 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`.
- 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.
@@ -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:
```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:
@@ -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
- 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
Report:
+2
View File
@@ -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
View File
@@ -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}`);
+3 -2
View File
@@ -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
+121 -75
View File
@@ -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,7 +414,8 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
// 4. Probe executor
await probeExecutor(config.executor, config.executorConfig);
// 5. Require clean working tree
// 5. Require clean working tree (skipped in none mode)
if (vcsKind !== "none") {
let isClean: boolean;
try {
isClean = vcsProvider.isClean(repoRoot);
@@ -394,19 +431,9 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
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}`);
+17 -5
View File
@@ -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)}`;
}
// ---------------------------------------------------------------------------
+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
// ---------------------------------------------------------------------------
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;
}