diff --git a/README.md b/README.md index dd76777..8c68eed 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,13 @@ agent-workflow decide --run --input decision.json Start a new workflow run. Requires a clean working tree. ```bash -agent-workflow start --plan --executor [--vcs ] [--max-cycles ] +agent-workflow start --plan --executor [--vcs ] [--max-cycles ] ``` -- `--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: diff --git a/package-lock.json b/package-lock.json index 9b4613a..2f196cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 54e35d2..e44b18c 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "node": ">=20" }, "dependencies": { - "fast-xml-parser": "^5.10.1" + "fast-xml-parser": "^5.10.1", + "ignore": "^7.0.6" } } diff --git a/skills/agent-workflow-host/SKILL.md b/skills/agent-workflow-host/SKILL.md index ae87ce2..61ab7ed 100644 --- a/skills/agent-workflow-host/SKILL.md +++ b/skills/agent-workflow-host/SKILL.md @@ -40,11 +40,12 @@ $agent-workflow-host 日志 [--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 ` --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 日志 [--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 --executor [--vcs ] +agent-workflow start --plan --executor [--vcs ] ``` 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: diff --git a/src/index.ts b/src/index.ts index 46f076b..1be3d1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/vcs-provider.ts b/src/vcs-provider.ts index bcdd73d..966c6cc 100644 --- a/src/vcs-provider.ts +++ b/src/vcs-provider.ts @@ -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 { + 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, + 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; + 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; + 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; + 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; + 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}`); diff --git a/src/workflow-args.ts b/src/workflow-args.ts index eacbf17..9f3f93a 100644 --- a/src/workflow-args.ts +++ b/src/workflow-args.ts @@ -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 --plan [--vcs ] [--max-cycles ] + agent-workflow start --executor --plan [--vcs ] [--max-cycles ] agent-workflow review --run agent-workflow retry-review --run agent-workflow retry-execute --run @@ -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 diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index d2b9dcb..1c554fa 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -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(); + const rootsToTry = new Set(); + 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(); - - for (const [, root] of Object.entries(candidates) as Array<[string, string | undefined]>) { - if (!root) continue; - - // Skip if we already loaded config from this root - if (configs.has(root)) continue; - - try { - const config = resolveWorkflowConfig(root, {}); - configs.set(root, { vcs: config.vcs, root }); - } catch (err) { - // Re-throw validation/parse errors (WorkflowConfigError) — these are real problems - if (err instanceof WorkflowConfigError) { - throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`); - } - // Re-throw file access errors unless the file simply doesn't exist (ENOENT). - // Permission denied, I/O errors, generic Error/TypeError, etc. must surface rather than be silently ignored. - const errCode = (err as any)?.code; - if (errCode === "ENOENT") { - // Config file doesn't exist — that's ok, treat as no project config - configs.set(root, { root }); - } else { - throw err; - } - } - } - - - // Extract project defaultVcs settings + // Extract project defaultVcs settings from configs const projectDefaults = Array.from(configs.values()) .map(c => c.vcs) .filter((v): v is VcsKind => v !== undefined); @@ -318,15 +315,25 @@ export async function resolveVcsSelection( ); } - // Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error const projectDefault = projectDefaults[0]; + if (projectDefault === "none") { + let configSourceRoot = cwd; + for (const c of configs.values()) { + if (c.vcs === "none") { + configSourceRoot = c.root; + break; + } + } + return { vcsKind: "none", repoRoot: cwd, configSourceRoot }; + } + if (candidates.git && candidates.svn) { // Ambiguous: need explicit selection if (projectDefault === "git") { - return { vcsKind: "git", repoRoot: candidates.git }; + return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git }; } else if (projectDefault === "svn") { - return { vcsKind: "svn", repoRoot: candidates.svn }; + return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn }; } else { throw new WorkflowEngineError( "Ambiguous VCS: both Git and SVN detected. " + @@ -334,9 +341,23 @@ export async function resolveVcsSelection( ); } } else if (candidates.git) { - return { vcsKind: "git", repoRoot: candidates.git }; + if (projectDefault === "svn") { + throw new WorkflowEngineError(`Configured VCS is "svn" but no SVN working copy was detected.`); + } + return { vcsKind: "git", repoRoot: candidates.git, configSourceRoot: candidates.git }; + } else if (candidates.svn) { + if (projectDefault === "git") { + throw new WorkflowEngineError(`Configured VCS is "git" but no Git repository was detected.`); + } + return { vcsKind: "svn", repoRoot: candidates.svn, configSourceRoot: candidates.svn }; } else { - return { vcsKind: "svn", repoRoot: candidates.svn! }; + // Neither detected + if (projectDefault === "git" || projectDefault === "svn") { + throw new WorkflowEngineError( + `Configured VCS is "${projectDefault}" but no such VCS repository was detected.` + ); + } + return { vcsKind: "none", repoRoot: cwd, configSourceRoot: cwd }; } } @@ -364,10 +385,25 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s const typedPlan = plan as WorkflowPlanV1; // 2. Resolve VCS selection using deterministic helper - const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined); + const { vcsKind, repoRoot, configSourceRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined); - // 3. Resolve full config from final repoRoot - const config = resolveWorkflowConfig(repoRoot, { + // Reject AGENT_WORKFLOW_DIR/workflow state root that is equal to or nested under target repoRoot in none mode + if (vcsKind === "none") { + const absRepoRoot = path.resolve(repoRoot); + const absStateRoot = path.resolve(workflowsRoot()); + const relative = path.relative(absRepoRoot, absStateRoot); + const isNestedOrEqual = !relative.startsWith("..") && !path.isAbsolute(relative); + if (isNestedOrEqual) { + throw new WorkflowEngineError( + `Workflow state directory (${absStateRoot}) cannot be equal to or nested under the repository root (${absRepoRoot}) in none VCS mode. ` + + `Please set the AGENT_WORKFLOW_DIR environment variable to an external location.`, + 4 + ); + } + } + + // 3. Resolve full config from configSourceRoot or final repoRoot + const config = resolveWorkflowConfig(configSourceRoot ?? repoRoot, { ...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}), ...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}), ...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}), @@ -378,35 +414,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s // 4. Probe executor await probeExecutor(config.executor, config.executorConfig); - // 5. Require clean working tree - let isClean: boolean; - try { - isClean = vcsProvider.isClean(repoRoot); - } catch (err) { - if (err instanceof VcsError) { - throw new WorkflowEngineError(err.message); + // 5. Require clean working tree (skipped in none mode) + if (vcsKind !== "none") { + let isClean: boolean; + try { + isClean = vcsProvider.isClean(repoRoot); + } catch (err) { + if (err instanceof VcsError) { + throw new WorkflowEngineError(err.message); + } + throw new WorkflowEngineError((err as Error).message); + } + if (!isClean) { + throw new WorkflowEngineError( + "Working tree has uncommitted changes. Commit or stash them before starting a workflow.", + 4, + ); } - throw new WorkflowEngineError((err as Error).message); - } - if (!isClean) { - throw new WorkflowEngineError( - "Working tree has uncommitted changes. Commit or stash them before starting a workflow.", - 4, - ); } - // 6. Capture baseline - let vcsBaseline: VcsBaseline; - try { - vcsBaseline = vcsProvider.captureBaseline(repoRoot); - } catch (err) { - if (err instanceof VcsError) { - throw new WorkflowEngineError(err.message); - } - throw new WorkflowEngineError((err as Error).message); - } - - // 7. Acquire lock (one active workflow per repo) + // 6. Acquire lock (one active workflow per repo) const runId = generateRunId(); try { acquireLock(repoRoot, runId); @@ -420,6 +447,20 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s const dir = makeRunDir(repoRoot, runId); fs.mkdirSync(dir, { recursive: true }); + // 7. Capture baseline + let vcsBaseline: VcsBaseline; + try { + vcsBaseline = vcsProvider.captureBaseline(repoRoot, dir); + } catch (err) { + try { + releaseLock(repoRoot, runId); + } catch {} + if (err instanceof VcsError) { + throw new WorkflowEngineError(err.message); + } + throw new WorkflowEngineError((err as Error).message); + } + const state = initWorkflowState({ runId, repoRoot, @@ -433,6 +474,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s state.executorConfig = config.executorConfig; state.timeoutSeconds = config.timeoutSeconds; state.enginePid = process.pid; + state.status = "executing"; writeState(dir, state); appendEvent(dir, { kind: "workflow_started", @@ -449,6 +491,8 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s process.stdout.write(` baseline: ${vcsBaseline.head}\n`); } else if (vcsBaseline.kind === "svn") { process.stdout.write(` baseline: r${vcsBaseline.revision}\n`); + } else if (vcsBaseline.kind === "none") { + process.stdout.write(` baseline: snapshot\n`); } process.stdout.write(` run dir: ${dir}\n\n`); @@ -484,6 +528,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s config: config.executorConfig, timeoutSeconds: state.timeoutSeconds, signal: ac.signal, + vcsKind, }); } catch (err) { cleanup(); @@ -568,7 +613,7 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise { // 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 { // 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 { // 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 { } 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 { 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}`); diff --git a/src/workflow-executor.ts b/src/workflow-executor.ts index cbedc69..9371a61 100644 --- a/src/workflow-executor.ts +++ b/src/workflow-executor.ts @@ -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)}`; } // --------------------------------------------------------------------------- diff --git a/src/workflow-none.test.ts b/src/workflow-none.test.ts new file mode 100644 index 0000000..3b05779 --- /dev/null +++ b/src/workflow-none.test.ts @@ -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 "); + }); + + 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); + }); +}); diff --git a/src/workflow-types.ts b/src/workflow-types.ts index cba5441..5ded763 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -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; }