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