diff --git a/README.md b/README.md index 80e2e1d..dd76777 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ - **Codex** acts as the host planner, reviewer, and final acceptance authority - **Reasonix, Claude Code, or Agy** executes implementation and fixes in the same session -- The workflow maintains atomic state, Git baseline gates, scope enforcement, and convergence limits +- The workflow maintains atomic state, VCS baseline gates, scope enforcement, and convergence limits +- Supports both **Git** and **SVN** working copies with strict validation ## Prerequisites - Node.js >= 20 -- Git repository +- Git repository or SVN working copy - At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), or [Agy](https://agy.dev) ## Installation @@ -76,9 +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 [--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. +- 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). + ### `agent-workflow review` Generate a Codex review bundle after executor completion. Checks HEAD stability and scope compliance. @@ -159,6 +164,16 @@ Terminate an active workflow. agent-workflow abort --run --reason "requirements changed" ``` +### `agent-workflow extend` + +Extend a `budget_exhausted` run with additional cycles. The run must have ended at `budget_exhausted` status with a valid session handle and a final `fix` decision. + +```bash +agent-workflow extend --run --additional-cycles +``` + +This allows continuation of the same executor session after reaching the cycle limit, preserving all audit history and convergence checks. + ## Configuration Create `agent-workflow.json` in your repository root: @@ -167,6 +182,7 @@ Create `agent-workflow.json` in your repository root: { "version": "1", "defaultExecutor": "reasonix", + "defaultVcs": "auto", "maxCycles": 5, "timeoutSeconds": 1800, "executors": { @@ -185,6 +201,8 @@ Create `agent-workflow.json` in your repository root: } ``` +- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, or `"svn"`. Default is `"auto"`. + ## Environment Variables - `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`) @@ -210,6 +228,20 @@ Any active state → completed | needs_human | blocked | budget_exhausted | abor All file modifications must stay within the approved scope. Out-of-scope changes trigger `awaiting_scope_resolution`, where Codex inspects each violation and decides whether to remove it or escalate to `needs_human`. +## VCS Support + +### Git +Standard Git repositories are fully supported with baseline HEAD tracking and diff generation. + +### SVN (Strict Mode) +SVN working copies are supported with strict validation: +- **Requires**: Single consistent revision (no mixed revisions) +- **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` + ## Convergence Limits The workflow stops with `needs_human` when: diff --git a/package-lock.json b/package-lock.json index 89c4265..9b4613a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "agent-workflow", "version": "0.1.0", "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.10.1" + }, "bin": { "agent-workflow": "bin/agent-workflow.js" }, @@ -524,6 +527,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -964,6 +979,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1070,6 +1097,45 @@ "node": ">=12.0.0" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1119,6 +1185,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1423,6 +1501,21 @@ "node": ">=12.20.0" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1544,6 +1637,21 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1820,6 +1928,21 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } } } } diff --git a/package.json b/package.json index 76adb29..54e35d2 100644 --- a/package.json +++ b/package.json @@ -51,5 +51,8 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "fast-xml-parser": "^5.10.1" } } diff --git a/skills/agent-workflow-host/SKILL.md b/skills/agent-workflow-host/SKILL.md index c42c1c7..ae87ce2 100644 --- a/skills/agent-workflow-host/SKILL.md +++ b/skills/agent-workflow-host/SKILL.md @@ -14,11 +14,12 @@ Support these explicit operations: ```text $agent-workflow-host 执行已确认计划 $agent-workflow-host 使用 claude 执行已确认计划 +$agent-workflow-host 使用 svn 执行已确认计划 $agent-workflow-host 继续 $agent-workflow-host 查看 $agent-workflow-host 清理后重试 $agent-workflow-host 执行器重试 -$agent-workflow-host 执行器重试 +$agent-workflow-host 延长 添加 个周期 $agent-workflow-host 中止 原因:需求变化 $agent-workflow-host 日志 [--follow] ``` @@ -34,15 +35,16 @@ $agent-workflow-host 日志 [--follow] - Codex owns plan normalization, finding classification, verification, and acceptance. - The executor owns implementation, tests, and fixes. During an active workflow, Codex must not edit target repository files itself. - `agent-workflow review` only prepares evidence for Codex. Do not invoke another reviewer as an acceptance gate. -- Never run or authorize `git commit`, `git push`, `git reset`, history rewriting, or branch deletion. +- Never run or authorize `git commit`, `git push`, `git reset`, `svn commit`, `svn switch`, `svn update`, history rewriting, or branch deletion. - Never broaden the approved scope or silently change requirements after start. Abort and create a new plan when requirements materially change. ## Start A Confirmed Plan -1. Resolve the target Git root and inspect the repository before execution. +1. Resolve the target VCS root (Git or SVN) and inspect the repository before execution. 2. Run these preflight checks: - `command -v agent-workflow` - - `git status --porcelain` must be empty. + - For Git: `git status --porcelain` must be empty. + - For SVN: `svn status` must show a clean working copy with no mixed revisions. - 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. @@ -64,7 +66,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 +agent-workflow start --plan --executor [--vcs ] ``` After starting, announce: @@ -72,7 +74,7 @@ After starting, announce: Run started. Monitor live output: agent-workflow logs --run --follow ``` -Honor an explicit `max-cycles` argument. Otherwise use project configuration and workflow defaults. +Honor an explicit `max-cycles` argument or `--vcs` flag. Otherwise use project configuration and workflow defaults. VCS is auto-detected if not specified. ## Drive The State Machine @@ -143,24 +145,42 @@ Create `HostDecisionV1` in the temporary directory: After `fix`, repeat review and decision for the new cycle. Respect the configured cycle budget and the engine's persistence/oscillation stop conditions. -## Resume, Inspect, Or Abort +## Resume, Inspect, Extend, Or Abort - For `查看 `, run `agent-workflow status --run --json`, summarize it, and do not advance the workflow. - For `继续 `, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory. -- For `清理后重试 `, run `agent-workflow retry-review --run ` after the approved scope artifacts have been removed. It must refuse when HEAD drift or out-of-scope paths remain. -- For `执行器重试 `, run `agent-workflow retry-execute --run ` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when HEAD has drifted, scope violations exist, or the cycle already produced review artifacts. -- Treat `completed`, `needs_human`, `blocked`, `budget_exhausted`, and `aborted` as terminal. Report them instead of attempting another cycle. +- For `清理后重试 `, run `agent-workflow retry-review --run ` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain. +- For `执行器重试 `, run `agent-workflow retry-execute --run ` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts. +- For `延长 添加 个周期`, run `agent-workflow extend --run --additional-cycles ` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session. +- Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle. +- `budget_exhausted` is terminal but can be extended with the extend command if all conditions are met. - For an explicit abort, run `agent-workflow abort --run --reason ` and report the resulting status. ## Failure Handling -- Missing session handle, executor failure, or HEAD drift must stop as `blocked`; do not work around the gate. +- Missing session handle, executor failure, or baseline drift (Git HEAD change, SVN revision change) must stop as `blocked`; do not work around the gate. - When a resume failed before producing cycle artifacts because the same session was busy, ask the user to close the owning executor process, then use `retry-execute`; never kill an unrelated process automatically. - Out-of-scope edits must enter `awaiting_scope_resolution`; inspect and remediate them through the same executor session. Repeated identical violations must stop as `needs_human`. - Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence. +- If the cycle budget is exhausted and more work remains, explain the `extend` command option to the user. - If interrupted, preserve the run ID. A later explicit `继续 ` invocation resumes from durable state. - If the user changes requirements, abort the active run and return to plan discussion. Do not mutate the frozen plan in place. +## VCS-Specific Guidance + +### Git Workflows +- Standard baseline tracking with HEAD SHA +- Diff includes tracked changes and unversioned additions +- Scope checking via `git diff --name-only` and `git ls-files` + +### SVN Workflows (Strict Mode) +- Baseline includes repository URL, UUID, and single working revision +- Before starting: verify `svn info` shows consistent state, no mixed revisions +- Diff includes tracked modifications, unversioned additions, deletions, and property changes +- The executor never runs `svn commit`, `svn switch`, or `svn update` +- Reject runs with switched paths, externals, conflicts, or incomplete states +- If mixed revisions detected: stop and instruct user to run `svn update` + ## Final Report Report: diff --git a/src/cli.ts b/src/cli.ts index b84a160..c815e72 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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}`); diff --git a/src/index.ts b/src/index.ts index 30f1cac..46f076b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/vcs-provider.ts b/src/vcs-provider.ts new file mode 100644 index 0000000..51a6e71 --- /dev/null +++ b/src/vcs-provider.ts @@ -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 { + const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" }); + const result = parser.parse(infoXml); + const revisions = new Set(); + + 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; +} diff --git a/src/workflow-args.ts b/src/workflow-args.ts index 88f5a7c..eacbf17 100644 --- a/src/workflow-args.ts +++ b/src/workflow-args.ts @@ -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 --plan [--max-cycles ] + agent-workflow start --executor --plan [--vcs ] [--max-cycles ] agent-workflow review --run agent-workflow retry-review --run agent-workflow retry-execute --run agent-workflow decide --run --input + agent-workflow extend --run --additional-cycles agent-workflow status --run [--json] agent-workflow abort --run --reason agent-workflow logs --run [--follow] [--tail ] @@ -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 --input decision.json agent-workflow decide --run --input - # read decision from stdin + agent-workflow extend --run --additional-cycles 3 agent-workflow status --run agent-workflow status --run --json agent-workflow abort --run --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}`); } diff --git a/src/workflow-config.ts b/src/workflow-config.ts index 3cdc282..a8c6ac6 100644 --- a/src/workflow-config.ts +++ b/src/workflow-config.ts @@ -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, diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts index c3bdb98..488ed75 100644 --- a/src/workflow-engine.ts +++ b/src/workflow-engine.ts @@ -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(); + + 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 { 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 { 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 { + // 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)); diff --git a/src/workflow-executor.ts b/src/workflow-executor.ts index e4e49d4..cbedc69 100644 --- a/src/workflow-executor.ts +++ b/src/workflow-executor.ts @@ -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(); diff --git a/src/workflow-state.ts b/src/workflow-state.ts index f703c97..015e306 100644 --- a/src/workflow-state.ts +++ b/src/workflow-state.ts @@ -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", diff --git a/src/workflow-svn-extend.test.ts b/src/workflow-svn-extend.test.ts new file mode 100644 index 0000000..e4cb8a0 --- /dev/null +++ b/src/workflow-svn-extend.test.ts @@ -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 = ` + + + + + + + +`; + + expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i); + }); + + it("validates file-external via XML", () => { + const statusXml = ` + + + + + + + +`; + + expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/external/i); + }); + + it("validates working-copy locked via XML", () => { + const statusXml = ` + + + + + + + +`; + + expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/locked/i); + }); + + it("validates obstruction via XML", () => { + const statusXml = ` + + + + + + + +`; + + expect(() => svnProvider.validateSvnStatusXml(statusXml)).toThrow(/obstruction/i); + }); + + it("validates incomplete via XML", () => { + const statusXml = ` + + + + + + + +`; + + 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(); + }); +}); diff --git a/src/workflow-types.ts b/src/workflow-types.ts index 1630227..cba5441 100644 --- a/src/workflow-types.ts +++ b/src/workflow-types.ts @@ -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;