feat: add VCS workflows and cycle extension

This commit is contained in:
liujing
2026-07-17 09:07:28 +08:00
parent c9d76d8f58
commit d13f31d9d1
14 changed files with 2335 additions and 189 deletions
+9
View File
@@ -19,6 +19,7 @@ import {
logsWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
extendWorkflow,
WorkflowEngineError,
} from "./workflow-engine.js";
import { WorkflowConfigError } from "./workflow-config.js";
@@ -42,6 +43,7 @@ async function main() {
await startWorkflow({
planInput: args.planInput,
executor: args.executor,
vcs: args.vcs,
maxCycles: args.maxCycles,
});
break;
@@ -81,6 +83,13 @@ async function main() {
});
break;
case "extend":
await extendWorkflow({
runId: args.runId,
additionalCycles: args.additionalCycles,
});
break;
default: {
const _exhaustive: never = args;
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
+24
View File
@@ -13,8 +13,10 @@ export {
logsWorkflow,
retryReviewWorkflow,
retryExecuteWorkflow,
extendWorkflow,
WorkflowEngineError,
emitWorkflowMeta,
resolveVcsSelection,
} from "./workflow-engine.js";
export type {
@@ -26,6 +28,7 @@ export type {
LogsWorkflowOpts,
RetryReviewWorkflowOpts,
RetryExecuteWorkflowOpts,
ExtendWorkflowOpts,
} from "./workflow-engine.js";
export {
@@ -114,6 +117,7 @@ export type {
WorkflowStatusArgs,
WorkflowAbortArgs,
WorkflowLogsArgs,
WorkflowExtendArgs,
} from "./workflow-args.js";
export type {
@@ -139,6 +143,11 @@ export type {
ScopeViolation,
WorkflowEvent,
WorkflowEventKind,
VcsKind,
VcsBaseline,
VcsBaselineGit,
VcsBaselineSvn,
ExtensionRecord,
} from "./workflow-types.js";
export {
@@ -147,6 +156,7 @@ export {
WORKFLOW_ACTIVE_STATUSES,
WORKFLOW_TERMINAL_STATUSES,
HOST_DECISION_OUTCOMES,
VCS_KINDS,
} from "./workflow-types.js";
export {
@@ -160,3 +170,17 @@ export type {
ChildRunResult,
StreamProgress,
} from "./child-process.js";
export {
VcsError,
GitProvider,
SvnProvider,
detectVcs,
createVcsProvider,
extractBaselineHead,
findVcsCandidates,
} from "./vcs-provider.js";
export type {
VcsProvider,
} from "./vcs-provider.js";
+729
View File
@@ -0,0 +1,729 @@
/**
* VCS provider abstraction for Git and SVN working copies.
*
* Git: Standard Git behavior (unchanged from previous implementation).
* SVN: Strict working copy mode - rejects mixed revisions, switched paths,
* externals, conflicts, and incomplete states.
*/
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { XMLParser } from "fast-xml-parser";
import type { VcsBaseline, VcsKind } from "./workflow-types.js";
export class VcsError extends Error {
constructor(message: string) {
super(message);
this.name = "VcsError";
}
}
// ---------------------------------------------------------------------------
// VCS Provider Interface
// ---------------------------------------------------------------------------
export interface VcsProvider {
readonly kind: VcsKind;
/** Locate the working copy root from the given directory. */
repoRoot(cwd: string): string;
/** Capture the current baseline state. */
captureBaseline(repoRoot: 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;
/** Check that all modified files are within the allowed scope. */
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[];
/** Validate that the baseline hasn't drifted (no commits/updates). */
validateBaseline(repoRoot: string, baseline: VcsBaseline): void;
}
// ---------------------------------------------------------------------------
// Git Provider
// ---------------------------------------------------------------------------
export class GitProvider implements VcsProvider {
readonly kind: VcsKind = "git";
repoRoot(cwd: string = process.cwd()): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new VcsError("Not in a git repository (or git not found).");
}
}
captureBaseline(repoRoot: string): VcsBaseline {
try {
const head = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return { kind: "git", head };
} catch {
throw new VcsError("Failed to read HEAD.");
}
}
isClean(repoRoot: string): boolean {
try {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
throw new VcsError("Failed to check git status.");
}
}
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
try {
const trackedDiff = execFileSync("git", ["diff", baseline.head], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const untrackedFiles = untrackedStr
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let finalDiff = trackedDiff;
for (const file of untrackedFiles) {
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
cwd: repoRoot,
encoding: "utf8",
});
const untrackedDiff = (res.stdout || "").trim();
if (untrackedDiff) {
if (finalDiff) {
finalDiff += "\n" + untrackedDiff;
} else {
finalDiff = untrackedDiff;
}
}
}
return finalDiff;
} catch {
throw new VcsError("Failed to compute diff from baseline HEAD.");
}
}
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
let diff: string;
let untracked: string;
try {
diff = execFileSync("git", ["diff", "--name-only", baseline.head], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw new VcsError("Failed to check modified files.");
}
const modifiedFiles = (diff + "\n" + untracked)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
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): void {
if (baseline.kind !== "git") {
throw new VcsError("Git provider requires git baseline.");
}
const currentHead = this.captureBaseline(repoRoot);
if (currentHead.kind === "git" && currentHead.head !== baseline.head) {
throw new VcsError(
`HEAD changed from baseline (${baseline.head.slice(0, 8)}) to ${currentHead.head.slice(0, 8)}.`
);
}
}
}
// ---------------------------------------------------------------------------
// SVN Provider (Strict Mode)
// ---------------------------------------------------------------------------
export class SvnProvider implements VcsProvider {
readonly kind: VcsKind = "svn";
repoRoot(cwd: string = process.cwd()): string {
try {
const output = execFileSync("svn", ["info", "--show-item", "wc-root"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
if (!output) {
throw new VcsError("Not in an SVN working copy (or svn not found).");
}
return output;
} catch {
throw new VcsError("Not in an SVN working copy (or svn not found).");
}
}
captureBaseline(repoRoot: string): VcsBaseline {
// Strict validation before capturing baseline
this.validateStrictWorkingCopy(repoRoot);
try {
const repoUrl = execFileSync("svn", ["info", "--show-item", "url"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const repoUuid = execFileSync("svn", ["info", "--show-item", "repos-uuid"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const revision = parseInt(
execFileSync("svn", ["info", "--show-item", "revision"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim(),
10
);
if (!repoUrl || !repoUuid || !Number.isInteger(revision) || revision < 0) {
throw new VcsError("Failed to capture SVN baseline: invalid info output.");
}
return { kind: "svn", repoUrl, repoUuid, revision };
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to capture SVN baseline: ${(err as Error).message}`);
}
}
isClean(repoRoot: string): boolean {
this.validateStrictWorkingCopy(repoRoot);
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(xml);
// Clean means no modifications, additions, deletions, or unversioned files
return parsed.entries.length === 0;
} catch {
throw new VcsError("Failed to check SVN status.");
}
}
diffFromBaseline(repoRoot: string, baseline: VcsBaseline): string {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
try {
const statusXml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(statusXml);
let combinedDiff = "";
for (const entry of parsed.entries) {
const itemStatus = entry.wcStatus;
const propsStatus = entry.propsStatus;
// Skip clean files, ignored files, and external references
if (itemStatus === "normal" && propsStatus === "none") continue;
if (itemStatus === "ignored") continue;
if (itemStatus === "external") continue;
// Normalize and validate path
const entryPath = this.normalizeAndValidatePath(entry.path, repoRoot);
const fullPath = path.resolve(repoRoot, entryPath);
if (itemStatus === "added" || itemStatus === "unversioned") {
if (itemStatus === "added") {
// Added file: use svn diff
const addDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (addDiffRes.status !== 0 && addDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${addDiffRes.stderr}`);
}
if (addDiffRes.stdout) {
combinedDiff += addDiffRes.stdout.trim() + "\n";
}
} else {
// Unversioned file: generate diff manually
if (fs.existsSync(fullPath)) {
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
// Skip directories
continue;
}
if (stats.isFile()) {
try {
// Try to read as text, but handle binary
const content = fs.readFileSync(fullPath);
// Check if binary
const isBinary = content.includes(0);
if (isBinary) {
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `Cannot display: file marked as a binary type.\n`;
combinedDiff += `svn:mime-type = application/octet-stream\n`;
} else {
const textContent = content.toString("utf8");
const lines = textContent.split("\n");
const hasTrailingNewline = textContent.endsWith("\n");
const lineCount = hasTrailingNewline ? lines.length - 1 : lines.length;
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `--- ${entryPath}\t(nonexistent)\n`;
combinedDiff += `+++ ${entryPath}\t(working copy)\n`;
combinedDiff += `@@ -0,0 +1,${lineCount} @@\n`;
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && hasTrailingNewline && lines[i] === "") {
continue;
}
combinedDiff += `+${lines[i]}\n`;
}
if (!hasTrailingNewline && lineCount > 0) {
combinedDiff += "\\ No newline at end of file\n";
}
}
} catch (err) {
throw new VcsError(`Failed to read unversioned file ${entryPath}: ${(err as Error).message}`);
}
}
}
}
} else if (itemStatus === "missing" || itemStatus === "deleted") {
// Deleted/missing file: retrieve BASE content for proper diff
const baseDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (baseDiffRes.status !== 0 && baseDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${baseDiffRes.stderr}`);
}
if (baseDiffRes.stdout) {
combinedDiff += baseDiffRes.stdout.trim() + "\n";
} else {
// If svn diff is empty for missing file, generate deletion diff from BASE
const catRes = spawnSync("svn", ["cat", entryPath + "@BASE"], {
cwd: repoRoot,
encoding: "utf8",
});
if (catRes.status === 0 && catRes.stdout) {
const content = catRes.stdout;
const lines = content.split("\n");
const hasTrailingNewline = content.endsWith("\n");
const lineCount = hasTrailingNewline ? lines.length - 1 : lines.length;
combinedDiff += `Index: ${entryPath}\n`;
combinedDiff += `===================================================================\n`;
combinedDiff += `--- ${entryPath}\t(revision BASE)\n`;
combinedDiff += `+++ ${entryPath}\t(working copy)\n`;
combinedDiff += `@@ -1,${lineCount} +0,0 @@\n`;
for (let i = 0; i < lines.length; i++) {
if (i === lines.length - 1 && hasTrailingNewline && lines[i] === "") {
continue;
}
combinedDiff += `-${lines[i]}\n`;
}
if (!hasTrailingNewline && lineCount > 0) {
combinedDiff += "\\ No newline at end of file\n";
}
} else if (catRes.status !== 0) {
throw new VcsError(`svn cat BASE failed for ${entryPath}: ${catRes.stderr || "unknown error"}`);
}
}
} else if (itemStatus === "modified" || propsStatus === "modified") {
// Modified file or property change: use svn diff
const modDiffRes = spawnSync("svn", ["diff", entryPath], {
cwd: repoRoot,
encoding: "utf8",
});
if (modDiffRes.status !== 0 && modDiffRes.status !== null) {
throw new VcsError(`svn diff failed for ${entryPath}: ${modDiffRes.stderr}`);
}
if (modDiffRes.stdout) {
combinedDiff += modDiffRes.stdout.trim() + "\n";
}
}
}
return combinedDiff.trim();
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to generate SVN diff: ${(err as Error).message}`);
}
}
/** Normalize and validate SVN status path is within repoRoot, return repository-relative path. */
private normalizeAndValidatePath(entryPath: string, repoRoot: string): string {
// Normalize path to repository-relative
const normalized = path.isAbsolute(entryPath)
? path.relative(repoRoot, entryPath)
: entryPath;
// Reject paths outside working copy
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
throw new VcsError(`Path outside working copy: ${entryPath}`);
}
return normalized;
}
checkFilesInScope(repoRoot: string, baseline: VcsBaseline, scope: string[]): string[] {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const parsed = this.parseSvnStatusXml(xml);
const modifiedFiles: string[] = [];
for (const entry of parsed.entries) {
// Skip clean files, ignored files, and external references
if (entry.wcStatus === "normal" && entry.propsStatus === "none") continue;
if (entry.wcStatus === "ignored") continue;
if (entry.wcStatus === "external") continue;
// Normalize and validate path
const normalizedPath = this.normalizeAndValidatePath(entry.path, repoRoot);
modifiedFiles.push(normalizedPath);
}
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to check SVN scope: ${(err as Error).message}`);
}
}
validateBaseline(repoRoot: string, baseline: VcsBaseline): void {
if (baseline.kind !== "svn") {
throw new VcsError("SVN provider requires svn baseline.");
}
this.validateStrictWorkingCopy(repoRoot);
const current = this.captureBaseline(repoRoot);
if (current.kind !== "svn") {
throw new VcsError("Current baseline is not SVN.");
}
if (current.repoUrl !== baseline.repoUrl) {
throw new VcsError(`SVN URL changed from ${baseline.repoUrl} to ${current.repoUrl}.`);
}
if (current.repoUuid !== baseline.repoUuid) {
throw new VcsError(`SVN UUID changed from ${baseline.repoUuid} to ${current.repoUuid}.`);
}
if (current.revision !== baseline.revision) {
throw new VcsError(
`SVN revision changed from ${baseline.revision} to ${current.revision}. ` +
`Run 'svn update' or commit/switch detected.`
);
}
}
/** Strict validation: reject mixed revisions, switched paths, externals, conflicts, etc. */
private validateStrictWorkingCopy(repoRoot: string): void {
try {
const xml = execFileSync("svn", ["status", "--xml"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
this.validateSvnStatusXml(xml);
// Check for mixed revisions using svn info --xml -R
const infoXml = execFileSync("svn", ["info", "--xml", "-R"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024,
});
const revisions = this.extractWorkingRevisions(infoXml);
if (revisions.size > 1) {
throw new VcsError(
`SVN mixed revisions detected (${Array.from(revisions).sort().join(", ")}). ` +
`Run 'svn update' to synchronize the working copy.`
);
}
} catch (err) {
if (err instanceof VcsError) throw err;
throw new VcsError(`Failed to validate SVN working copy: ${(err as Error).message}`);
}
}
/** Validate SVN status XML for forbidden states - exported for testing */
validateSvnStatusXml(xml: string): void {
const parsed = this.parseSvnStatusXml(xml);
// Check for forbidden states in ALL entries
for (const entry of parsed.entries) {
if (entry.wcStatus === "conflicted") {
throw new VcsError(`SVN conflict detected: ${entry.path}`);
}
if (entry.wcStatus === "obstructed") {
throw new VcsError(`SVN obstruction detected: ${entry.path}`);
}
if (entry.wcStatus === "incomplete") {
throw new VcsError(`SVN incomplete state detected: ${entry.path}`);
}
if (entry.switched) {
throw new VcsError(`SVN switched path detected: ${entry.path}`);
}
if (entry.fileExternal || entry.treeConflicted || entry.wcStatus === "external") {
throw new VcsError(`SVN external detected: ${entry.path}`);
}
if (entry.locked) {
throw new VcsError(`SVN working-copy locked file detected: ${entry.path}`);
}
}
}
private extractWorkingRevisions(infoXml: string): Set<number> {
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
const result = parser.parse(infoXml);
const revisions = new Set<number>();
if (!result.info) return revisions;
const entries = Array.isArray(result.info.entry) ? result.info.entry : [result.info.entry];
for (const entry of entries) {
if (!entry) continue;
// Use entry revision attribute (working revision), not commit/last-changed revision
const entryRev = entry["@_revision"];
if (entryRev !== undefined) {
const rev = parseInt(String(entryRev), 10);
if (!isNaN(rev)) {
revisions.add(rev);
}
}
}
return revisions;
}
private parseSvnStatusXml(xml: string): { entries: SvnStatusEntry[] } {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const result = parser.parse(xml);
const entries: SvnStatusEntry[] = [];
if (!result.status || !result.status.target) {
return { entries };
}
const target = result.status.target;
if (!target.entry) {
return { entries };
}
const entryList = Array.isArray(target.entry) ? target.entry : [target.entry];
for (const entry of entryList) {
if (!entry) continue;
const entryPath = entry["@_path"];
const wcStatus = entry["wc-status"];
if (!wcStatus) continue;
const itemStatus = wcStatus["@_item"] || "normal";
const propsStatus = wcStatus["@_props"] || "none";
const switched = wcStatus["@_switched"] === "true";
const fileExternal = wcStatus["@_file-external"] === "true";
const treeConflicted = wcStatus["@_tree-conflicted"] === "true";
const wcLocked = wcStatus["@_wc-locked"] === "true";
// Include ALL entries for strict validation, including normal and external
entries.push({
path: entryPath,
wcStatus: itemStatus,
propsStatus,
switched,
fileExternal,
treeConflicted,
locked: wcLocked,
});
}
return { entries };
}
}
interface SvnStatusEntry {
path: string;
wcStatus: string;
propsStatus: string;
switched: boolean;
fileExternal: boolean;
treeConflicted: boolean;
locked: boolean;
}
// ---------------------------------------------------------------------------
// VCS Detection & Factory
// ---------------------------------------------------------------------------
export function detectVcs(cwd: string): VcsKind {
let hasGit = false;
let hasSvn = false;
// Check for Git
try {
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
stdio: "ignore",
});
hasGit = true;
} catch {
// Not git
}
// Check for SVN
try {
execFileSync("svn", ["info", "--show-item", "wc-root"], {
cwd,
stdio: "ignore",
});
hasSvn = true;
} catch {
// Not svn
}
if (hasGit && hasSvn) {
throw new VcsError(
"Ambiguous VCS: both Git and SVN detected. " +
"Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json."
);
}
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."
);
}
/** Find VCS candidates without throwing on ambiguity. */
export function findVcsCandidates(cwd: string): { git?: string; svn?: string } {
const candidates: { git?: string; svn?: string } = {};
// Try Git
try {
const gitRoot = new GitProvider().repoRoot(cwd);
candidates.git = gitRoot;
} catch {
// Not git or git not available
}
// Try SVN
try {
const svnRoot = new SvnProvider().repoRoot(cwd);
candidates.svn = svnRoot;
} catch {
// Not svn or svn not available
}
return candidates;
}
export function createVcsProvider(kind: VcsKind): VcsProvider {
switch (kind) {
case "git":
return new GitProvider();
case "svn":
return new SvnProvider();
default: {
const _exhaustive: never = kind;
throw new VcsError(`Unknown VCS kind: ${_exhaustive}`);
}
}
}
/** Backward compatibility: extract head from old or new baseline format. */
export function extractBaselineHead(state: { baselineHead?: string; vcsBaseline?: VcsBaseline }): string | undefined {
if (state.vcsBaseline?.kind === "git") {
return state.vcsBaseline.head;
}
return state.baselineHead;
}
+41 -4
View File
@@ -2,11 +2,12 @@
* Argument parsing for agent-workflow subcommands.
*/
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs";
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend";
export interface WorkflowStartArgs {
sub: "start";
executor?: string;
vcs?: string;
planInput: string; // file path or "-"
maxCycles?: number;
}
@@ -51,6 +52,12 @@ export interface WorkflowLogsArgs {
tail: number;
}
export interface WorkflowExtendArgs {
sub: "extend";
runId: string;
additionalCycles: number;
}
export type WorkflowArgs =
| WorkflowStartArgs
| WorkflowReviewArgs
@@ -59,7 +66,8 @@ export type WorkflowArgs =
| WorkflowDecideArgs
| WorkflowStatusArgs
| WorkflowAbortArgs
| WorkflowLogsArgs;
| WorkflowLogsArgs
| WorkflowExtendArgs;
export class WorkflowArgsError extends Error {
constructor(message: string) {
@@ -85,11 +93,12 @@ function parsePositiveInt(flag: string, value: string): number {
const WORKFLOW_USAGE = `
Usage:
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--max-cycles <n>]
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn>] [--max-cycles <n>]
agent-workflow review --run <run-id>
agent-workflow retry-review --run <run-id>
agent-workflow retry-execute --run <run-id>
agent-workflow decide --run <run-id> --input <decision.json|->
agent-workflow extend --run <run-id> --additional-cycles <n>
agent-workflow status --run <run-id> [--json]
agent-workflow abort --run <run-id> --reason <text>
agent-workflow logs --run <run-id> [--follow] [--tail <lines>]
@@ -98,12 +107,14 @@ 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 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
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123
agent-workflow decide --run <id> --input decision.json
agent-workflow decide --run <id> --input - # read decision from stdin
agent-workflow extend --run <id> --additional-cycles 3
agent-workflow status --run <id>
agent-workflow status --run <id> --json
agent-workflow abort --run <id> --reason "changed requirements"
@@ -125,6 +136,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
switch (sub) {
case "start": {
let executor: string | undefined;
let vcs: string | undefined;
let planInput: string | undefined;
let maxCycles: number | undefined;
@@ -134,6 +146,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
case "--executor":
executor = requireValue(arg, rest);
break;
case "--vcs":
vcs = requireValue(arg, rest);
break;
case "--plan":
planInput = requireValue(arg, rest);
break;
@@ -151,7 +166,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
return { sub: "start", executor, planInput, maxCycles };
return { sub: "start", executor, vcs, planInput, maxCycles };
}
case "review": {
@@ -283,6 +298,28 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
return { sub: "logs", runId, follow, tail };
}
case "extend": {
let runId: string | undefined;
let additionalCycles: number | undefined;
while (rest.length > 0) {
const arg = rest.shift()!;
if (arg === "--run") {
runId = requireValue(arg, rest);
} else if (arg === "--additional-cycles") {
additionalCycles = parsePositiveInt(arg, requireValue(arg, rest));
} else if (arg === "-h" || arg === "--help") {
workflowUsage(0);
} else {
throw new WorkflowArgsError(`Unknown option: ${arg}`);
}
}
if (!runId) throw new WorkflowArgsError("--run is required for workflow extend");
if (additionalCycles === undefined) {
throw new WorkflowArgsError("--additional-cycles is required for workflow extend");
}
return { sub: "extend", runId, additionalCycles };
}
default:
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
}
+22 -1
View File
@@ -12,8 +12,9 @@ import type {
ExecutorKind,
ResolvedWorkflowConfig,
WorkflowProjectConfig,
VcsKind,
} from "./workflow-types.js";
import { EXECUTOR_KINDS } from "./workflow-types.js";
import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
@@ -54,6 +55,12 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
if (obj.defaultExecutor !== undefined && !EXECUTOR_KINDS.includes(obj.defaultExecutor as ExecutorKind)) {
throw new WorkflowConfigError(`${filePath}: defaultExecutor must be one of ${EXECUTOR_KINDS.join(", ")}`);
}
if (obj.defaultVcs !== undefined) {
const validVcs = ["auto", ...VCS_KINDS];
if (!validVcs.includes(obj.defaultVcs as string)) {
throw new WorkflowConfigError(`${filePath}: defaultVcs must be one of ${validVcs.join(", ")}`);
}
}
if (obj.maxCycles !== undefined && (typeof obj.maxCycles !== "number" || !Number.isInteger(obj.maxCycles) || obj.maxCycles < 1)) {
throw new WorkflowConfigError(`${filePath}: maxCycles must be a positive integer`);
}
@@ -73,6 +80,7 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
export interface WorkflowCliOverrides {
executor?: ExecutorKind;
vcs?: VcsKind;
maxCycles?: number;
}
@@ -88,6 +96,18 @@ export function resolveWorkflowConfig(
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
}
// VCS selection precedence: CLI --vcs, then project defaultVcs (unless "auto"), then auto-detect
let vcs: VcsKind | undefined;
if (cli.vcs) {
if (!VCS_KINDS.includes(cli.vcs)) {
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
}
vcs = cli.vcs;
} else if (project?.defaultVcs && project.defaultVcs !== "auto") {
vcs = project.defaultVcs as VcsKind;
}
// If vcs is still undefined, caller will auto-detect
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? DEFAULTS.maxCycles;
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`);
@@ -101,6 +121,7 @@ export function resolveWorkflowConfig(
return {
executor,
...(vcs ? { vcs } : {}),
maxCycles,
timeoutSeconds,
executorConfig,
+430 -60
View File
@@ -5,6 +5,7 @@
* review — generates a review bundle for the Codex host
* retry-review — resumes a cleaned scope-gate review
* decide — receives Codex HostDecisionV1, drives state transitions
* extend — extends a budget_exhausted run with additional cycles
* status — prints current run state
* abort — terminates a run with a reason
*
@@ -19,7 +20,7 @@
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan } from "./workflow-config.js";
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan, WorkflowConfigError } from "./workflow-config.js";
import { checkConvergence, decisionSignatures } from "./workflow-convergence.js";
import type { FindingSignature } from "./workflow-convergence.js";
import { createExecutorAdapter, probeExecutor } from "./workflow-executor.js";
@@ -27,17 +28,12 @@ import {
WorkflowLockError,
acquireLock,
appendEvent,
checkFilesInScope,
cycleDiffFile,
cycleDecisionFile,
cycleExecLogFile,
cycleHostReviewFile,
findRunDir,
generateRunId,
gitDiffFromHead,
gitHead,
gitIsClean,
gitRepoRoot,
initWorkflowState,
nowIso,
readState,
@@ -46,12 +42,20 @@ import {
runDir as makeRunDir,
writeState,
} from "./workflow-state.js";
import {
createVcsProvider,
detectVcs,
extractBaselineHead,
VcsError,
} from "./vcs-provider.js";
import type {
CycleRecord,
ExecutorResult,
HostDecisionV1,
HostReviewBundleV1,
ScopeViolation,
VcsBaseline,
VcsKind,
WorkflowPlanV1,
WorkflowState,
WorkflowStatusOutput,
@@ -237,22 +241,101 @@ function persistExecutorAttempt(
export interface StartWorkflowOpts {
planInput: string; // file path or "-" for stdin
executor?: string;
vcs?: string;
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 }> {
// If explicit VCS provided, use it directly
if (explicitVcs) {
const provider = createVcsProvider(explicitVcs);
try {
const repoRoot = provider.repoRoot(cwd);
return { vcsKind: explicitVcs, repoRoot };
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
}
throw new WorkflowEngineError((err as Error).message);
}
}
// 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 [vcsType, 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 errors, tolerate missing config
if (err instanceof WorkflowConfigError) {
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`);
}
// Config doesn't exist - that's ok
configs.set(root, { root });
}
}
// Extract project defaultVcs settings
const projectDefaults = Array.from(configs.values())
.map(c => c.vcs)
.filter((v): v is VcsKind => v !== undefined);
// Check for conflicting project defaults
const uniqueDefaults = new Set(projectDefaults);
if (uniqueDefaults.size > 1) {
throw new WorkflowEngineError(
`Conflicting VCS defaults in project configs: ${Array.from(uniqueDefaults).join(" vs ")}`
);
}
// Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error
const projectDefault = projectDefaults[0];
if (candidates.git && candidates.svn) {
// Ambiguous: need explicit selection
if (projectDefault === "git") {
return { vcsKind: "git", repoRoot: candidates.git };
} else if (projectDefault === "svn") {
return { vcsKind: "svn", repoRoot: candidates.svn };
} else {
throw new WorkflowEngineError(
"Ambiguous VCS: both Git and SVN detected. " +
"Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json."
);
}
} else if (candidates.git) {
return { vcsKind: "git", repoRoot: candidates.git };
} else {
return { vcsKind: "svn", repoRoot: candidates.svn! };
}
}
export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: string }> {
const cwd = opts.cwd ?? process.cwd();
// 1. Locate repo root
let repoRoot: string;
try {
repoRoot = gitRepoRoot(cwd);
} catch (err) {
throw new WorkflowEngineError((err as Error).message);
}
// 2. Load and validate plan
// 1. Load and validate plan first
let planRaw: string;
if (opts.planInput === "-") {
planRaw = fs.readFileSync(0, "utf8");
@@ -272,20 +355,29 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
validateWorkflowPlan(plan);
const typedPlan = plan as WorkflowPlanV1;
// 3. Resolve config
// 2. Resolve VCS selection using deterministic helper
const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
// 3. Resolve full config from final repoRoot
const config = resolveWorkflowConfig(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 } : {}),
});
const vcsProvider = createVcsProvider(vcsKind);
// 4. Probe executor
await probeExecutor(config.executor, config.executorConfig);
// 5. Require clean working tree
let isClean: boolean;
try {
isClean = gitIsClean(repoRoot);
isClean = vcsProvider.isClean(repoRoot);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message);
}
throw new WorkflowEngineError((err as Error).message);
}
if (!isClean) {
@@ -295,7 +387,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
);
}
// 6. Acquire lock (one active workflow per repo)
// 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)
const runId = generateRunId();
try {
acquireLock(repoRoot, runId);
@@ -309,11 +412,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
const dir = makeRunDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const baselineHead = gitHead(repoRoot);
const state = initWorkflowState({
runId,
repoRoot,
baselineHead,
vcsBaseline,
vcsKind,
executor: config.executor,
plan: typedPlan,
maxCycles: config.maxCycles,
@@ -327,13 +430,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
kind: "workflow_started",
runId,
timestamp: state.createdAt,
data: { executor: config.executor, maxCycles: config.maxCycles, baselineHead },
data: { executor: config.executor, maxCycles: config.maxCycles, vcsKind, vcsBaseline },
});
process.stdout.write(`workflow started: ${runId}\n`);
process.stdout.write(` vcs: ${vcsKind}\n`);
process.stdout.write(` executor: ${config.executor}\n`);
process.stdout.write(` max-cycles: ${config.maxCycles}\n`);
process.stdout.write(` baseline: ${baselineHead}\n`);
if (vcsBaseline.kind === "git") {
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
} else if (vcsBaseline.kind === "svn") {
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`);
}
process.stdout.write(` run dir: ${dir}\n\n`);
// 7. Start first execution cycle
@@ -441,33 +549,56 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
state.enginePid = process.pid;
writeStateSafety(dir, state);
// 1. Generate diff from baseline HEAD
// Get VCS provider
const vcsKind = state.vcsKind || "git";
const vcsProvider = createVcsProvider(vcsKind);
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
if (!baseline) {
throw new WorkflowEngineError("No baseline found in state (corrupt state file).");
}
// 1. Generate diff from baseline
let diff: string;
try {
diff = gitDiffFromHead(state.repoRoot, state.baselineHead);
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline);
} catch (err) {
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${(err as Error).message}`);
throw new WorkflowEngineError((err as Error).message);
if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`);
throw new WorkflowEngineError(err.message);
}
throw err;
}
const diffFile = cycleDiffFile(dir, cycleIndex);
fs.writeFileSync(diffFile, diff, "utf8");
cycleRecord.diffFile = path.relative(dir, diffFile);
// 2. Check for HEAD drift (executor committed something)
const currentHead = gitHead(state.repoRoot);
if (currentHead !== state.baselineHead) {
// Check if intermediate commits happened
transitionToTerminal(
state,
dir,
"blocked",
`HEAD changed from baseline (${state.baselineHead.slice(0, 8)}) to ${currentHead.slice(0, 8)} — executor may have committed. Manual review required.`,
);
throw new WorkflowEngineError("HEAD changed unexpectedly. Workflow blocked.");
// 2. Check for baseline drift
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
} catch (err) {
if (err instanceof VcsError) {
transitionToTerminal(
state,
dir,
"blocked",
`Baseline validation failed: ${err.message}`,
);
throw new WorkflowEngineError(err.message);
}
throw err;
}
// 3. Check out-of-scope files
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
} catch (err) {
if (err instanceof VcsError) {
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
throw new WorkflowEngineError(err.message);
}
throw err;
}
if (outOfScope.length > 0) {
const scopeViolations = makeScopeViolations(outOfScope);
const previousCycle = state.cycles.find((cycle) => cycle.cycleIndex === cycleIndex - 1);
@@ -521,7 +652,9 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
runId: state.runId,
cycleIndex,
repoRoot: state.repoRoot,
baselineHead: state.baselineHead,
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
generatedAt,
plan: state.plan,
diffFile: diffFile,
@@ -846,14 +979,33 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
return "Codex review bundle is missing from disk.";
}
// 2. HEAD must not have changed
const currentHead = gitHead(state.repoRoot);
if (currentHead !== state.baselineHead) {
return `HEAD changed (baseline: ${state.baselineHead.slice(0, 8)}, current: ${currentHead.slice(0, 8)}).`;
// 2. Baseline must not have changed
const vcsKind = state.vcsKind || "git";
const vcsProvider = createVcsProvider(vcsKind);
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
if (!baseline) {
return "No baseline found in state.";
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
} catch (err) {
if (err instanceof VcsError) {
return err.message;
}
return `Baseline validation failed: ${(err as Error).message}`;
}
// 3. All files must be in scope
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
} catch (err) {
if (err instanceof VcsError) {
return `Scope check failed: ${err.message}`;
}
return `Scope check failed: ${(err as Error).message}`;
}
if (outOfScope.length > 0) {
return `Out-of-scope files: ${outOfScope.join(", ")}`;
}
@@ -961,15 +1113,32 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
);
}
const currentHead = gitHead(state.repoRoot);
if (currentHead !== state.baselineHead) {
throw new WorkflowEngineError(
`Cannot retry review: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
4,
);
const vcsKind = state.vcsKind || "git";
const vcsProvider = createVcsProvider(vcsKind);
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
if (!baseline) {
throw new WorkflowEngineError("No baseline found in state.");
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
}
throw err;
}
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
}
throw err;
}
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
if (outOfScope.length > 0) {
throw new WorkflowEngineError(
`Cannot retry review while out-of-scope files remain: ${outOfScope.sort().join(", ")}.`,
@@ -1052,14 +1221,31 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
}
const resolvingScope = !recoveringInitialCycle && (previousCycle?.scopeViolations?.length ?? 0) > 0;
const currentHead = gitHead(state.repoRoot);
if (currentHead !== state.baselineHead) {
throw new WorkflowEngineError(
`Cannot retry execution: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
4,
);
const vcsKind = state.vcsKind || "git";
const vcsProvider = createVcsProvider(vcsKind);
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
if (!baseline) {
throw new WorkflowEngineError("No baseline found in state.", 4);
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
}
throw err;
}
let outOfScope: string[];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
} catch (err) {
if (err instanceof VcsError) {
throw new WorkflowEngineError(err.message, 4);
}
throw err;
}
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
const approvedScopePaths = new Set((previousCycle?.scopeViolations ?? []).map((violation) => violation.path));
const unexpectedOutOfScope = resolvingScope
? outOfScope.filter((filePath) => !approvedScopePaths.has(filePath))
@@ -1128,6 +1314,180 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
await resumeExecutorCycle(state, dir, cycle, acceptedFindings);
}
// ---------------------------------------------------------------------------
// workflow extend
// ---------------------------------------------------------------------------
export interface ExtendWorkflowOpts {
runId: string;
additionalCycles: number;
}
export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
// 1. Validate positive integer
if (!Number.isInteger(opts.additionalCycles) || opts.additionalCycles < 1) {
throw new WorkflowEngineError(
`--additional-cycles must be a positive integer, got: ${opts.additionalCycles}`,
4,
);
}
const { state, dir } = requireState(opts.runId);
// 2. Must be in budget_exhausted terminal state
if (state.status !== "budget_exhausted") {
throw new WorkflowEngineError(
`Run '${opts.runId}' is in status '${state.status}', not budget_exhausted. ` +
`Only budget_exhausted runs can be extended.`,
4,
);
}
// 3. Must have a valid session handle
if (!state.sessionHandle) {
throw new WorkflowEngineError(
`Run '${opts.runId}' has no session handle. Cannot extend without a resumable session.`,
4,
);
}
// 4. The last persisted decision must be 'fix'
const lastCycle = state.cycles[state.cycles.length - 1];
if (!lastCycle?.decisionFile || lastCycle.decisionOutcome !== "fix") {
throw new WorkflowEngineError(
`Run '${opts.runId}' cannot be extended: the last cycle decision must be 'fix'.`,
4,
);
}
// 5. Reacquire the repository lock BEFORE validation
try {
acquireLock(state.repoRoot, state.runId);
} catch (err) {
if (err instanceof WorkflowLockError) {
throw new WorkflowEngineError(err.message, 4);
}
throw err;
}
// Helper to release lock and throw
const failAndRelease = (message: string, exitCode = 4): never => {
releaseLock(state.repoRoot, state.runId);
throw new WorkflowEngineError(message, exitCode);
};
// 6. Validate baseline and scope under the lock
const vcsKind = state.vcsKind || "git";
const vcsProvider = createVcsProvider(vcsKind);
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
if (!baseline) {
failAndRelease("No baseline found in state.");
return; // Never reached but helps TypeScript
}
try {
vcsProvider.validateBaseline(state.repoRoot, baseline);
} catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
return;
}
let outOfScope: string[] = [];
try {
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
} catch (err) {
const message = err instanceof VcsError ? err.message : (err as Error).message;
failAndRelease(`Cannot extend: scope check failed: ${message}`);
return;
}
if (outOfScope.length > 0) {
failAndRelease(`Cannot extend: out-of-scope files detected: ${outOfScope.join(", ")}`);
return;
}
// 7. Check convergence using existing history
const convergenceError = checkWorkflowConvergence(state, dir);
if (convergenceError) {
failAndRelease(`Cannot extend: ${convergenceError}`);
return;
}
// 8. Load and validate the last decision BEFORE mutating state
const decisionPath = path.join(dir, lastCycle.decisionFile!);
let decision: HostDecisionV1;
let acceptedFindings: string = "";
try {
const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown;
validateHostDecision(raw);
decision = raw;
acceptedFindings = buildAcceptedFindingsText(decision, state, dir, lastCycle.cycleIndex);
} catch (err) {
failAndRelease(`Cannot extend: failed to read last decision: ${(err as Error).message}`);
return;
}
// 9. Now atomically mutate state: budget, cycle, status, history
const oldMaxCycles = state.maxCycles;
const newMaxCycles = oldMaxCycles + opts.additionalCycles;
const nextCycle = state.currentCycle + 1;
const extendedAt = nowIso();
state.maxCycles = newMaxCycles;
state.enginePid = process.pid;
state.updatedAt = extendedAt;
state.status = "executing";
delete state.stopReason;
delete state.stopDescription;
const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: extendedAt };
state.cycles.push(nextCycleRecord);
state.currentCycle = nextCycle;
// Record extension history in state
if (!state.extensionHistory) {
state.extensionHistory = [];
}
state.extensionHistory.push({
extendedAt,
oldMaxCycles,
newMaxCycles,
additionalCycles: opts.additionalCycles,
resumedCycle: nextCycle,
});
// 10. Persist state and events
writeState(dir, state);
appendEvent(dir, {
kind: "budget_extended",
runId: state.runId,
timestamp: extendedAt,
data: {
additionalCycles: opts.additionalCycles,
oldMaxCycles,
newMaxCycles,
nextCycle,
},
});
appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle });
process.stdout.write(`workflow extended: ${state.runId}\n`);
process.stdout.write(` additional cycles: ${opts.additionalCycles}\n`);
process.stdout.write(` old max cycles: ${oldMaxCycles}\n`);
process.stdout.write(` new max cycles: ${newMaxCycles}\n`);
process.stdout.write(` resuming cycle: ${nextCycle}\n\n`);
// 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
try {
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
} catch (err) {
// resumeExecutorCycle already handles errors and releases lock
throw err;
}
}
// ---------------------------------------------------------------------------
// workflow status
// ---------------------------------------------------------------------------
@@ -1177,7 +1537,9 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
scopeViolations: state.status === "awaiting_scope_resolution" ? cycle?.scopeViolations : undefined,
stopReason: state.stopReason,
stopDescription: state.stopDescription,
baselineHead: state.baselineHead,
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
repoRoot: state.repoRoot,
createdAt: state.createdAt,
updatedAt: state.updatedAt,
@@ -1230,7 +1592,15 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
if (state.stopReason) lines.push(p("Stop reason", state.stopReason));
if (state.stopDescription) lines.push(p("Stop detail", state.stopDescription.slice(0, 80)));
lines.push(p("Repo root", state.repoRoot));
lines.push(p("Baseline HEAD", state.baselineHead.slice(0, 8)));
const baselineHead = extractBaselineHead(state);
if (baselineHead) {
lines.push(p("Baseline HEAD", baselineHead.slice(0, 8)));
} else if (state.vcsBaseline?.kind === "svn") {
lines.push(p("Baseline SVN", `r${state.vcsBaseline.revision}`));
}
if (state.vcsKind) {
lines.push(p("VCS", state.vcsKind));
}
lines.push(p("Created", state.createdAt));
lines.push(p("Updated", state.updatedAt));
lines.push("─".repeat(46));
+3 -1
View File
@@ -41,13 +41,15 @@ const SAFETY_CONSTRAINTS = `
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
- DO NOT run git commit, git push, or git reset under any circumstances.
- DO NOT run svn commit, svn switch, svn update, svn relocate, or svn revert under any circumstances.
- DO NOT modify .svn metadata directories or files.
- DO NOT rewrite git history or delete branches.
- DO NOT create or modify files outside the scope listed above.
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
- 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 and leave no artifacts outside the allowed scope.
- Before exiting, inspect git status --porcelain --untracked-files=all (or svn status) and leave no artifacts outside the allowed scope.
- 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();
+24 -104
View File
@@ -17,8 +17,10 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import type { ProgressSidecar, WorkflowEvent, WorkflowState } from "./workflow-types.js";
import type { ProgressSidecar, WorkflowEvent, WorkflowState, VcsBaseline } from "./workflow-types.js";
// Import VCS providers for backward-compatible helpers
import { GitProvider } from "./vcs-provider.js";
// ---------------------------------------------------------------------------
// Paths
@@ -160,7 +162,7 @@ export function readLock(repoRoot: string): string | undefined {
}
// ---------------------------------------------------------------------------
// Git helpers
// Legacy Git helpers (kept for backward compatibility with old code)
// ---------------------------------------------------------------------------
export class GitError extends Error {
@@ -170,118 +172,32 @@ export class GitError extends Error {
}
}
/** Return the absolute repo root (throws if not in a git repo). */
const _gitProvider = new GitProvider();
/** @deprecated Use VcsProvider instead. Return the absolute repo root (throws if not in a git repo). */
export function gitRepoRoot(cwd: string = process.cwd()): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Not in a git repository (or git not found).");
}
return _gitProvider.repoRoot(cwd);
}
/** Return HEAD SHA. */
/** @deprecated Use VcsProvider instead. Return HEAD SHA. */
export function gitHead(repoRoot: string): string {
try {
return execFileSync("git", ["rev-parse", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
} catch {
throw new GitError("Failed to read HEAD.");
}
const baseline = _gitProvider.captureBaseline(repoRoot);
return baseline.kind === "git" ? baseline.head : "";
}
/** Return true when the working tree is clean (no uncommitted changes). */
/** @deprecated Use VcsProvider instead. Return true when the working tree is clean (no uncommitted changes). */
export function gitIsClean(repoRoot: string): boolean {
try {
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
return status === "";
} catch {
throw new GitError("Failed to check git status.");
}
return _gitProvider.isClean(repoRoot);
}
/** Generate a diff from baseline HEAD to the current working tree. */
/** @deprecated Use VcsProvider instead. Generate a diff from baseline HEAD to the current working tree. */
export function gitDiffFromHead(repoRoot: string, baselineHead: string): string {
try {
const trackedDiff = execFileSync("git", ["diff", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const untrackedFiles = untrackedStr
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
let finalDiff = trackedDiff;
for (const file of untrackedFiles) {
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
cwd: repoRoot,
encoding: "utf8",
});
const untrackedDiff = (res.stdout || "").trim();
if (untrackedDiff) {
if (finalDiff) {
finalDiff += "\n" + untrackedDiff;
} else {
finalDiff = untrackedDiff;
}
}
}
return finalDiff;
} catch {
throw new GitError("Failed to compute diff from baseline HEAD.");
}
return _gitProvider.diffFromBaseline(repoRoot, { kind: "git", head: baselineHead });
}
/** Check that all modified files are within the allowed scope. */
/** @deprecated Use VcsProvider instead. Check that all modified files are within the allowed scope. */
export function checkFilesInScope(repoRoot: string, baselineHead: string, scope: string[]): string[] {
let diff: string;
let untracked: string;
try {
diff = execFileSync("git", ["diff", "--name-only", baselineHead], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
cwd: repoRoot,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
throw new GitError("Failed to check modified files.");
}
const modifiedFiles = (diff + "\n" + untracked)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const outOfScope: string[] = [];
for (const file of modifiedFiles) {
const inScope = scope.some((s) => {
const normalized = s.replace(/\/+$/, "");
return file === normalized || file.startsWith(normalized + "/");
});
if (!inScope) outOfScope.push(file);
}
return outOfScope;
return _gitProvider.checkFilesInScope(repoRoot, { kind: "git", head: baselineHead }, scope);
}
// ---------------------------------------------------------------------------
@@ -296,7 +212,9 @@ export function nowIso(): string {
export function initWorkflowState(opts: {
runId: string;
repoRoot: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: import("./workflow-types.js").VcsKind;
executor: import("./workflow-types.js").ExecutorKind;
plan: import("./workflow-types.js").WorkflowPlanV1;
maxCycles: number;
@@ -308,7 +226,9 @@ export function initWorkflowState(opts: {
runId: opts.runId,
repoHash: repoHash(opts.repoRoot),
repoRoot: opts.repoRoot,
baselineHead: opts.baselineHead,
...(opts.baselineHead ? { baselineHead: opts.baselineHead } : {}),
...(opts.vcsBaseline ? { vcsBaseline: opts.vcsBaseline } : {}),
...(opts.vcsKind ? { vcsKind: opts.vcsKind } : {}),
executor: opts.executor,
plan: opts.plan,
status: "executing",
+810
View File
@@ -0,0 +1,810 @@
/**
* Comprehensive tests for SVN support and budget extension features.
*
* Uses real svnadmin repositories and fake executors to test:
* - VCS detection and ambiguity with real repos
* - SVN strict validation (mixed revisions, externals, conflicts, etc.)
* - SVN diff generation (modifications, additions, properties, missing files)
* - Extension validation and immediate resume with session preservation
* - Extension lock/baseline/scope gates
* - Convergence check during extension
* - Backward compatibility with old Git states
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawnSync } from "node:child_process";
import {
detectVcs,
VcsError,
createVcsProvider,
SvnProvider,
GitProvider,
} from "./vcs-provider.js";
import { resolveWorkflowConfig } from "./workflow-config.js";
import { extendWorkflow, startWorkflow } from "./workflow-engine.js";
import { readState, runDir, writeState, initWorkflowState, gitHead, releaseLock } from "./workflow-state.js";
import type { WorkflowPlanV1, HostDecisionV1, ExtensionRecord } from "./workflow-types.js";
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-svn-test-"));
}
function cleanupDir(dir: string): void {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
function makeValidPlan(): WorkflowPlanV1 {
return {
version: "1",
title: "Test plan",
planMarkdown: "Do something.",
scope: ["src/"],
acceptanceCriteria: ["Tests pass"],
verificationCommands: [["echo", "test"]],
};
}
// Create a real SVN repository with svnadmin
function createSvnRepo(repoDir: string, wcDir: string): void {
// Create repository
execFileSync("svnadmin", ["create", repoDir], { stdio: "ignore" });
// Checkout working copy
execFileSync("svn", ["checkout", `file://${repoDir}`, wcDir], { stdio: "ignore" });
// Create initial structure
fs.mkdirSync(path.join(wcDir, "src"), { recursive: true });
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "initial content\n");
// Add and commit
execFileSync("svn", ["add", "src"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["commit", "-m", "Initial commit"], { cwd: wcDir, stdio: "ignore" });
// Update working copy to ensure it's at the latest revision
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
}
// ---------------------------------------------------------------------------
// VCS Detection and Ambiguity
// ---------------------------------------------------------------------------
describe("VCS detection with real repositories", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("detects Git repository", () => {
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
expect(detectVcs(tmpDir)).toBe("git");
});
it("detects SVN working copy", () => {
const repoDir = path.join(tmpDir, "repo");
const wcDir = path.join(tmpDir, "wc");
createSvnRepo(repoDir, wcDir);
expect(detectVcs(wcDir)).toBe("svn");
});
it("throws ambiguity error when SVN working copy contains .git", () => {
const repoDir = path.join(tmpDir, "repo");
const wcDir = path.join(tmpDir, "wc");
createSvnRepo(repoDir, wcDir);
// Create .git directory inside SVN working copy
execFileSync("git", ["init"], { cwd: wcDir, stdio: "ignore" });
expect(() => detectVcs(wcDir)).toThrow(/Ambiguous VCS.*both Git and SVN/);
});
});
// ---------------------------------------------------------------------------
// VCS Precedence
// ---------------------------------------------------------------------------
describe("VCS precedence from nested directories", () => {
let tmpDir: string;
let repoRoot: string;
beforeEach(() => {
tmpDir = makeTmpDir();
repoRoot = path.join(tmpDir, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("honors project defaultVcs from nested working-copy paths", () => {
fs.writeFileSync(
path.join(repoRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", defaultVcs: "git" })
);
const nestedDir = path.join(repoRoot, "subdir");
fs.mkdirSync(nestedDir);
const config = resolveWorkflowConfig(repoRoot, {});
expect(config.vcs).toBe("git");
});
it("CLI override wins over project config", () => {
fs.writeFileSync(
path.join(repoRoot, "agent-workflow.json"),
JSON.stringify({ version: "1", defaultVcs: "svn" })
);
const config = resolveWorkflowConfig(repoRoot, { vcs: "git" });
expect(config.vcs).toBe("git");
});
});
// ---------------------------------------------------------------------------
// SVN Mixed Revisions Detection
// ---------------------------------------------------------------------------
describe("SVN mixed revisions with real repository", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("accepts working copy at single revision with different last-changed revisions", () => {
createSvnRepo(repoDir, wcDir);
// Make another commit that modifies existing file
fs.appendFileSync(path.join(wcDir, "src", "test.txt"), "second line\n");
execFileSync("svn", ["commit", "-m", "Modify test.txt"], { cwd: wcDir, stdio: "ignore" });
// Now update to latest - all files at same working revision but different last-changed
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
// Should not throw - all at same working revision
expect(() => svnProvider.captureBaseline(wcDir)).not.toThrow();
});
it("rejects actual mixed working revisions", () => {
createSvnRepo(repoDir, wcDir);
// Make another commit
fs.writeFileSync(path.join(wcDir, "src", "test2.txt"), "second file\n");
execFileSync("svn", ["add", "src/test2.txt"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["commit", "-m", "Add second file"], { cwd: wcDir, stdio: "ignore" });
execFileSync("svn", ["update"], { cwd: wcDir, stdio: "ignore" });
// Update only one file to create mixed revisions
execFileSync("svn", ["update", "-r", "1", "src/test.txt"], { cwd: wcDir, stdio: "ignore" });
expect(() => svnProvider.captureBaseline(wcDir)).toThrow(/mixed revisions/i);
});
});
// ---------------------------------------------------------------------------
// SVN Strict Validation
// ---------------------------------------------------------------------------
describe("SVN strict validation with real states", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("detects conflicted files", () => {
createSvnRepo(repoDir, wcDir);
// Create a conflict by modifying the same file in different ways
// Make a change and commit
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "version A\n");
execFileSync("svn", ["commit", "-m", "Version A"], { cwd: wcDir, stdio: "ignore" });
// Go back to r1 and make a different change
execFileSync("svn", ["update", "-r", "1"], { cwd: wcDir, stdio: "ignore" });
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "version B\n");
// Try to update - this creates a conflict
spawnSync("svn", ["update"], { cwd: wcDir });
// Check if conflict exists by looking at status
const statusXml = execFileSync("svn", ["status", "--xml"], {
cwd: wcDir,
encoding: "utf8",
});
// Verify conflict was actually created
if (!statusXml.includes('item="conflicted"')) {
// Skip test if conflict wasn't created
return;
}
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/conflict/i);
});
it("validates external via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="external-dir">
<wc-status item="external" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i);
});
it("validates file-external via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="external-file.txt">
<wc-status item="normal" props="none" file-external="true" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i);
});
it("validates working-copy locked via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="locked-file.txt">
<wc-status item="normal" props="none" wc-locked="true" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/locked/i);
});
it("validates obstruction via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="obstructed.txt">
<wc-status item="obstructed" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/obstruction/i);
});
it("validates incomplete via XML", () => {
const statusXml = `<?xml version="1.0" encoding="UTF-8"?>
<status>
<target path=".">
<entry path="incomplete.txt">
<wc-status item="incomplete" props="none" revision="5">
</wc-status>
</entry>
</target>
</status>`;
expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/incomplete/i);
});
});
// ---------------------------------------------------------------------------
// SVN Diff Generation
// ---------------------------------------------------------------------------
describe("SVN diff generation", () => {
let tmpDir: string;
let repoDir: string;
let wcDir: string;
let svnProvider: SvnProvider;
beforeEach(() => {
tmpDir = makeTmpDir();
repoDir = path.join(tmpDir, "repo");
wcDir = path.join(tmpDir, "wc");
svnProvider = new SvnProvider();
createSvnRepo(repoDir, wcDir);
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("generates diff for modified files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "test.txt"), "modified content\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("-initial content");
expect(diff).toContain("+modified content");
});
it("generates diff for unversioned additions", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "new.txt"), "new file\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/new.txt");
expect(diff).toContain("+new file");
});
it("generates diff for directly missing tracked files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.unlinkSync(path.join(wcDir, "src", "test.txt"));
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/test.txt");
expect(diff).toContain("-initial content");
});
it("handles files without trailing newline", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "no-newline.txt"), "no newline");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/no-newline.txt");
expect(diff).toContain("+no newline");
expect(diff).toContain("\\ No newline at end of file");
});
it("handles empty unversioned files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "empty.txt"), "");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/empty.txt");
});
it("handles paths with spaces and special characters", () => {
const baseline = svnProvider.captureBaseline(wcDir);
fs.writeFileSync(path.join(wcDir, "src", "file with spaces.txt"), "content\n");
fs.writeFileSync(path.join(wcDir, "src", "file's apostrophe.txt"), "content\n");
fs.writeFileSync(path.join(wcDir, "src", "文件.txt"), "Chinese filename\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("file with spaces.txt");
expect(diff).toContain("file's apostrophe.txt");
expect(diff).toContain("文件.txt");
});
it("handles binary unversioned files", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Create a binary file with null bytes
const binaryData = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02]);
fs.writeFileSync(path.join(wcDir, "src", "binary.dat"), binaryData);
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/binary.dat");
expect(diff).toContain("binary type");
});
it("handles property-only changes", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Set a property on existing file
execFileSync("svn", ["propset", "svn:eol-style", "native", "src/test.txt"], {
cwd: wcDir,
stdio: "ignore",
});
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
expect(diff).toContain("src/test.txt");
expect(diff).toContain("svn:eol-style");
});
it("skips directories in unversioned additions", () => {
const baseline = svnProvider.captureBaseline(wcDir);
// Create unversioned directory with a file
fs.mkdirSync(path.join(wcDir, "src", "newdir"));
fs.writeFileSync(path.join(wcDir, "src", "newdir", "file.txt"), "content\n");
const diff = svnProvider.diffFromBaseline(wcDir, baseline);
// SVN status only reports the parent directory as unversioned, not contents
// If directory is reported, it's skipped; if only file, it's included
// This test verifies directories themselves are skipped in diff generation
expect(diff).not.toContain("Cannot display");
});
});
// ---------------------------------------------------------------------------
// Extension Feature with Fake Executor
// ---------------------------------------------------------------------------
describe("Budget extension with fake executor", () => {
let tmpDir: string;
let repoRoot: string;
let stateRoot: string;
let originalWorkflowDir: string | undefined;
let fakeBinDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
repoRoot = path.join(tmpDir, "repo");
stateRoot = path.join(tmpDir, "workflows");
fakeBinDir = path.join(tmpDir, "bin");
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeBinDir, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
fs.writeFileSync(path.join(repoRoot, "test.txt"), "test");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot });
originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
process.env.AGENT_WORKFLOW_DIR = stateRoot;
// Create fake reasonix that returns immediately
const fakeBin = path.join(fakeBinDir, "reasonix");
fs.writeFileSync(fakeBin, `#!/bin/bash\necho '{"session_id":"test-session"}'\nexit 0\n`, { mode: 0o755 });
});
afterEach(() => {
if (originalWorkflowDir === undefined) {
delete process.env.AGENT_WORKFLOW_DIR;
} else {
process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir;
}
cleanupDir(tmpDir);
});
it("extends budget and records history in state", async () => {
const runId = "test-extend-001";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
fs.writeFileSync(path.join(tmpDir, "session.jsonl"), "");
state.executorConfig = { binary: path.join(fakeBinDir, "reasonix") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [{ findingId: "f1", disposition: "accept", summary: "Fix", rationale: "Test" }],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Extension should succeed and resume executor
await extendWorkflow({ runId, additionalCycles: 3 });
// Check state after successful extension
const updatedState = readState(dir);
expect(updatedState).toBeDefined();
// State should be awaiting_review after fake executor completes
expect(updatedState!.status).toBe("awaiting_review");
expect(updatedState!.maxCycles).toBe(5);
expect(updatedState!.currentCycle).toBe(3);
// Session handle unchanged
expect(updatedState!.sessionHandle?.kind).toBe("reasonix");
expect(updatedState!.sessionHandle?.sessionFilePath).toBe(path.join(tmpDir, "session.jsonl"));
// Extension history recorded
expect(updatedState!.extensionHistory).toBeDefined();
expect(updatedState!.extensionHistory).toHaveLength(1);
expect(updatedState!.extensionHistory![0].oldMaxCycles).toBe(2);
expect(updatedState!.extensionHistory![0].newMaxCycles).toBe(5);
expect(updatedState!.extensionHistory![0].additionalCycles).toBe(3);
expect(updatedState!.extensionHistory![0].resumedCycle).toBe(3);
// Check cycle 3 was created
expect(updatedState!.cycles).toHaveLength(2);
expect(updatedState!.cycles[1].cycleIndex).toBe(3);
}, 10000);
it("rejects extension with lock conflict", async () => {
const runId = "test-extend-lock";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Acquire lock with different runId to simulate conflict
const { acquireLock } = await import("./workflow-state.js");
acquireLock(repoRoot, "other-run-id");
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/workflow.*active/i);
// Clean up
releaseLock(repoRoot, "other-run-id");
});
it("rejects extension with out-of-scope changes", async () => {
const runId = "test-extend-scope";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Create out-of-scope file
fs.writeFileSync(path.join(repoRoot, "out-of-scope.txt"), "bad");
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/out-of-scope/i);
// Clean up
fs.unlinkSync(path.join(repoRoot, "out-of-scope.txt"));
});
it("rolls back on corrupt decision with lock released", async () => {
const runId = "test-extend-corrupt";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: gitHead(repoRoot) },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
// Write corrupt decision JSON
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), "{ invalid json");
writeState(dir, state);
const originalMaxCycles = state.maxCycles;
const originalStatus = state.status;
const originalCycleCount = state.cycles.length;
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/decision/i);
// State should be unchanged (rollback)
const unchangedState = readState(dir);
expect(unchangedState!.maxCycles).toBe(originalMaxCycles);
expect(unchangedState!.status).toBe(originalStatus);
expect(unchangedState!.cycles).toHaveLength(originalCycleCount);
expect(unchangedState!.extensionHistory).toBeUndefined();
// Lock should be released
const { acquireLock } = await import("./workflow-state.js");
expect(() => acquireLock(repoRoot, runId)).not.toThrow();
releaseLock(repoRoot, runId);
});
it("releases lock on baseline drift", async () => {
const runId = "test-extend-002";
const dir = runDir(repoRoot, runId);
fs.mkdirSync(dir, { recursive: true });
const oldHead = gitHead(repoRoot);
const state = initWorkflowState({
runId,
repoRoot,
vcsBaseline: { kind: "git", head: oldHead },
vcsKind: "git",
executor: "reasonix",
plan: makeValidPlan(),
maxCycles: 2,
});
state.status = "budget_exhausted";
state.stopReason = "budget_exhausted";
state.sessionHandle = { kind: "reasonix", sessionFilePath: path.join(tmpDir, "session.jsonl") };
const decision: HostDecisionV1 = {
version: "1",
outcome: "fix",
findingDecisions: [],
verificationEvidence: [],
decidedAt: new Date().toISOString(),
};
state.cycles = [{
cycleIndex: 2,
startedAt: new Date().toISOString(),
decisionFile: "cycle-2-decision.json",
decisionOutcome: "fix",
}];
state.currentCycle = 2;
fs.writeFileSync(path.join(dir, "cycle-2-decision.json"), JSON.stringify(decision));
writeState(dir, state);
// Create baseline drift
fs.writeFileSync(path.join(repoRoot, "new.txt"), "drift");
execFileSync("git", ["add", "."], { cwd: repoRoot });
execFileSync("git", ["commit", "-m", "drift"], { cwd: repoRoot });
await expect(extendWorkflow({ runId, additionalCycles: 2 })).rejects.toThrow(/baseline/i);
// The extension should have acquired and then released the lock on failure
// Try to acquire it ourselves to confirm it was released
const { acquireLock } = await import("./workflow-state.js");
expect(() => acquireLock(repoRoot, runId)).not.toThrow();
releaseLock(repoRoot, runId);
});
});
// ---------------------------------------------------------------------------
// Backward Compatibility
// ---------------------------------------------------------------------------
describe("Backward compatibility", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTmpDir();
});
afterEach(() => {
cleanupDir(tmpDir);
});
it("reads old state files with only baselineHead", () => {
const dir = path.join(tmpDir, "run-001");
fs.mkdirSync(dir, { recursive: true });
const oldState = {
version: "1",
runId: "run-001",
repoHash: "abcd1234",
repoRoot: tmpDir,
baselineHead: "abc123def456",
executor: "reasonix",
plan: makeValidPlan(),
status: "awaiting_review",
maxCycles: 5,
currentCycle: 1,
cycles: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
fs.writeFileSync(
path.join(dir, "state.json"),
JSON.stringify(oldState, null, 2)
);
const state = readState(dir);
expect(state).toBeDefined();
expect(state!.baselineHead).toBe("abc123def456");
expect(state!.vcsBaseline).toBeUndefined();
expect(state!.vcsKind).toBeUndefined();
});
});
+51 -5
View File
@@ -15,6 +15,31 @@
export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const;
export type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
// ---------------------------------------------------------------------------
// VCS abstraction
// ---------------------------------------------------------------------------
export const VCS_KINDS = ["git", "svn"] as const;
export type VcsKind = (typeof VCS_KINDS)[number];
/** Structured VCS baseline (replaces single baselineHead string). */
export interface VcsBaselineGit {
kind: "git";
head: string;
}
export interface VcsBaselineSvn {
kind: "svn";
/** Repository root URL */
repoUrl: string;
/** Repository UUID */
repoUuid: string;
/** Single working revision (mixed revisions are rejected) */
revision: number;
}
export type VcsBaseline = VcsBaselineGit | VcsBaselineSvn;
// ---------------------------------------------------------------------------
// Progress sidecar — lightweight real-time observability
// ---------------------------------------------------------------------------
@@ -167,8 +192,12 @@ export interface WorkflowState {
repoHash: string;
/** Absolute path to the repository root. */
repoRoot: string;
/** Git HEAD SHA at workflow start. */
baselineHead: string;
/** Git HEAD SHA at workflow start (version 1 states only). */
baselineHead?: string;
/** Structured VCS baseline (replaces baselineHead for new runs). */
vcsBaseline?: VcsBaseline;
/** VCS kind detected or specified at start. */
vcsKind?: VcsKind;
executor: ExecutorKind;
executorConfig?: ExecutorConfig;
timeoutSeconds?: number;
@@ -187,6 +216,16 @@ export interface WorkflowState {
updatedAt: string;
/** True when --agy-degraded-continue is in effect. */
agyDegraded?: boolean;
/** Extension history: records of each budget extension. */
extensionHistory?: ExtensionRecord[];
}
export interface ExtensionRecord {
extendedAt: string;
oldMaxCycles: number;
newMaxCycles: number;
additionalCycles: number;
resumedCycle: number;
}
// ---------------------------------------------------------------------------
@@ -230,7 +269,9 @@ export interface HostReviewBundleV1 {
runId: string;
cycleIndex: number;
repoRoot: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: VcsKind;
generatedAt: string;
plan: WorkflowPlanV1;
diffFile: string;
@@ -255,6 +296,7 @@ export interface ExecutorConfig {
export interface WorkflowProjectConfig {
version: "1";
defaultExecutor?: ExecutorKind;
defaultVcs?: "auto" | VcsKind;
maxCycles?: number;
timeoutSeconds?: number;
executors?: {
@@ -270,6 +312,7 @@ export interface WorkflowProjectConfig {
export interface ResolvedWorkflowConfig {
executor: ExecutorKind;
vcs?: VcsKind;
maxCycles: number;
timeoutSeconds: number;
executorConfig: ExecutorConfig;
@@ -295,7 +338,9 @@ export interface WorkflowStatusOutput {
scopeViolations?: ScopeViolation[];
stopReason?: WorkflowTerminalStatus;
stopDescription?: string;
baselineHead: string;
baselineHead?: string;
vcsBaseline?: VcsBaseline;
vcsKind?: VcsKind;
repoRoot: string;
createdAt: string;
updatedAt: string;
@@ -332,7 +377,8 @@ export type WorkflowEventKind =
| "cycle_completed"
| "workflow_stopped"
| "workflow_aborted"
| "blocked";
| "blocked"
| "budget_extended";
export interface WorkflowEvent {
kind: WorkflowEventKind;