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
|
||||
- **Reasonix, Claude Code, or Agy** executes implementation and fixes in the same session
|
||||
- The workflow maintains atomic state, Git baseline gates, scope enforcement, and convergence limits
|
||||
- The workflow maintains atomic state, VCS baseline gates, scope enforcement, and convergence limits
|
||||
- Supports both **Git** and **SVN** working copies with strict validation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 20
|
||||
- Git repository
|
||||
- Git repository or SVN working copy
|
||||
- At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), or [Agy](https://agy.dev)
|
||||
|
||||
## Installation
|
||||
@@ -76,9 +77,13 @@ agent-workflow decide --run <run-id> --input decision.json
|
||||
Start a new workflow run. Requires a clean working tree.
|
||||
|
||||
```bash
|
||||
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--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`
|
||||
|
||||
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 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
|
||||
|
||||
Create `agent-workflow.json` in your repository root:
|
||||
@@ -167,6 +182,7 @@ Create `agent-workflow.json` in your repository root:
|
||||
{
|
||||
"version": "1",
|
||||
"defaultExecutor": "reasonix",
|
||||
"defaultVcs": "auto",
|
||||
"maxCycles": 5,
|
||||
"timeoutSeconds": 1800,
|
||||
"executors": {
|
||||
@@ -185,6 +201,8 @@ Create `agent-workflow.json` in your repository root:
|
||||
}
|
||||
```
|
||||
|
||||
- `defaultVcs` - Default VCS kind: `"auto"` (detect), `"git"`, or `"svn"`. Default is `"auto"`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`)
|
||||
@@ -210,6 +228,20 @@ Any active state → completed | needs_human | blocked | budget_exhausted | abor
|
||||
|
||||
All file modifications must stay within the approved scope. Out-of-scope changes trigger `awaiting_scope_resolution`, where Codex inspects each violation and decides whether to remove it or escalate to `needs_human`.
|
||||
|
||||
## VCS Support
|
||||
|
||||
### Git
|
||||
Standard Git repositories are fully supported with baseline HEAD tracking and diff generation.
|
||||
|
||||
### SVN (Strict Mode)
|
||||
SVN working copies are supported with strict validation:
|
||||
- **Requires**: Single consistent revision (no mixed revisions)
|
||||
- **Rejects**: Switched paths, externals, conflicts, incomplete states, locked files
|
||||
- **Baseline**: Repository URL, UUID, and working revision
|
||||
- **Diff**: Tracks modifications, additions (including unversioned), deletions, and property changes
|
||||
- Use `svn update` to synchronize revisions before starting a workflow
|
||||
- The workflow never runs `svn commit`, `svn switch`, or `svn update`
|
||||
|
||||
## Convergence Limits
|
||||
|
||||
The workflow stops with `needs_human` when:
|
||||
|
||||
Generated
+123
@@ -8,6 +8,9 @@
|
||||
"name": "agent-workflow",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.10.1"
|
||||
},
|
||||
"bin": {
|
||||
"agent-workflow": "bin/agent-workflow.js"
|
||||
},
|
||||
@@ -524,6 +527,18 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
|
||||
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
@@ -964,6 +979,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/anynum": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1070,6 +1097,45 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
|
||||
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"xml-naming": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.10.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
|
||||
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodable/entities": "^3.0.0",
|
||||
"fast-xml-builder": "^1.2.0",
|
||||
"is-unsafe": "^2.0.0",
|
||||
"path-expression-matcher": "^1.6.2",
|
||||
"strnum": "^2.4.1",
|
||||
"xml-naming": "^0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1119,6 +1185,18 @@
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unsafe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
|
||||
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1423,6 +1501,21 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -1544,6 +1637,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
|
||||
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anynum": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -1820,6 +1928,21 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,5 +51,8 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^5.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ Support these explicit operations:
|
||||
```text
|
||||
$agent-workflow-host 执行已确认计划
|
||||
$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> 添加 <n> 个周期
|
||||
$agent-workflow-host 中止 <run-id> 原因:需求变化
|
||||
$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.
|
||||
- The executor owns implementation, tests, and fixes. During an active workflow, Codex must not edit target repository files itself.
|
||||
- `agent-workflow review` only prepares evidence for Codex. Do not invoke another reviewer as an acceptance gate.
|
||||
- Never run or authorize `git commit`, `git push`, `git reset`, history rewriting, or branch deletion.
|
||||
- Never run or authorize `git commit`, `git push`, `git reset`, `svn commit`, `svn switch`, `svn update`, history rewriting, or branch deletion.
|
||||
- Never broaden the approved scope or silently change requirements after start. Abort and create a new plan when requirements materially change.
|
||||
|
||||
## Start A Confirmed Plan
|
||||
|
||||
1. Resolve the target Git root and inspect the repository before execution.
|
||||
1. Resolve the target VCS root (Git or SVN) and inspect the repository before execution.
|
||||
2. Run these preflight checks:
|
||||
- `command -v agent-workflow`
|
||||
- `git status --porcelain` must be empty.
|
||||
- For Git: `git status --porcelain` must be empty.
|
||||
- For SVN: `svn status` must show a clean working copy with no mixed revisions.
|
||||
- Probe the selected executor with `<executor> --version`.
|
||||
- Confirm no unresolved active workflow prevents a new run.
|
||||
3. Do not install tools, commit, or stash automatically. Report the exact missing prerequisite and stop.
|
||||
@@ -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:
|
||||
|
||||
```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:
|
||||
@@ -72,7 +74,7 @@ After starting, announce:
|
||||
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
|
||||
|
||||
@@ -143,24 +145,42 @@ Create `HostDecisionV1` in the temporary directory:
|
||||
|
||||
After `fix`, repeat review and decision for the new cycle. Respect the configured cycle budget and the engine's persistence/oscillation stop conditions.
|
||||
|
||||
## Resume, Inspect, Or Abort
|
||||
## Resume, Inspect, Extend, Or Abort
|
||||
|
||||
- For `查看 <run-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>`, 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-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.
|
||||
- Treat `completed`, `needs_human`, `blocked`, `budget_exhausted`, and `aborted` as terminal. Report them instead of attempting another cycle.
|
||||
- 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 baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
|
||||
- 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.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Missing session handle, executor failure, or HEAD drift must stop as `blocked`; do not work around the gate.
|
||||
- Missing session handle, executor failure, or baseline drift (Git HEAD change, SVN revision change) must stop as `blocked`; do not work around the gate.
|
||||
- When a resume failed before producing cycle artifacts because the same session was busy, ask the user to close the owning executor process, then use `retry-execute`; never kill an unrelated process automatically.
|
||||
- Out-of-scope edits must enter `awaiting_scope_resolution`; inspect and remediate them through the same executor session. Repeated identical violations must stop as `needs_human`.
|
||||
- Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence.
|
||||
- If the cycle budget is exhausted and more work remains, explain the `extend` command option to the user.
|
||||
- If interrupted, preserve the run ID. A later explicit `继续 <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.
|
||||
|
||||
## VCS-Specific Guidance
|
||||
|
||||
### Git Workflows
|
||||
- Standard baseline tracking with HEAD SHA
|
||||
- Diff includes tracked changes and unversioned additions
|
||||
- Scope checking via `git diff --name-only` and `git ls-files`
|
||||
|
||||
### SVN Workflows (Strict Mode)
|
||||
- Baseline includes repository URL, UUID, and single working revision
|
||||
- Before starting: verify `svn info` shows consistent state, no mixed revisions
|
||||
- Diff includes tracked modifications, unversioned additions, deletions, and property changes
|
||||
- The executor never runs `svn commit`, `svn switch`, or `svn update`
|
||||
- Reject runs with switched paths, externals, conflicts, or incomplete states
|
||||
- If mixed revisions detected: stop and instruct user to run `svn update`
|
||||
|
||||
## Final Report
|
||||
|
||||
Report:
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
logsWorkflow,
|
||||
retryReviewWorkflow,
|
||||
retryExecuteWorkflow,
|
||||
extendWorkflow,
|
||||
WorkflowEngineError,
|
||||
} from "./workflow-engine.js";
|
||||
import { WorkflowConfigError } from "./workflow-config.js";
|
||||
@@ -42,6 +43,7 @@ async function main() {
|
||||
await startWorkflow({
|
||||
planInput: args.planInput,
|
||||
executor: args.executor,
|
||||
vcs: args.vcs,
|
||||
maxCycles: args.maxCycles,
|
||||
});
|
||||
break;
|
||||
@@ -81,6 +83,13 @@ async function main() {
|
||||
});
|
||||
break;
|
||||
|
||||
case "extend":
|
||||
await extendWorkflow({
|
||||
runId: args.runId,
|
||||
additionalCycles: args.additionalCycles,
|
||||
});
|
||||
break;
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = args;
|
||||
throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`);
|
||||
|
||||
@@ -13,8 +13,10 @@ export {
|
||||
logsWorkflow,
|
||||
retryReviewWorkflow,
|
||||
retryExecuteWorkflow,
|
||||
extendWorkflow,
|
||||
WorkflowEngineError,
|
||||
emitWorkflowMeta,
|
||||
resolveVcsSelection,
|
||||
} from "./workflow-engine.js";
|
||||
|
||||
export type {
|
||||
@@ -26,6 +28,7 @@ export type {
|
||||
LogsWorkflowOpts,
|
||||
RetryReviewWorkflowOpts,
|
||||
RetryExecuteWorkflowOpts,
|
||||
ExtendWorkflowOpts,
|
||||
} from "./workflow-engine.js";
|
||||
|
||||
export {
|
||||
@@ -114,6 +117,7 @@ export type {
|
||||
WorkflowStatusArgs,
|
||||
WorkflowAbortArgs,
|
||||
WorkflowLogsArgs,
|
||||
WorkflowExtendArgs,
|
||||
} from "./workflow-args.js";
|
||||
|
||||
export type {
|
||||
@@ -139,6 +143,11 @@ export type {
|
||||
ScopeViolation,
|
||||
WorkflowEvent,
|
||||
WorkflowEventKind,
|
||||
VcsKind,
|
||||
VcsBaseline,
|
||||
VcsBaselineGit,
|
||||
VcsBaselineSvn,
|
||||
ExtensionRecord,
|
||||
} from "./workflow-types.js";
|
||||
|
||||
export {
|
||||
@@ -147,6 +156,7 @@ export {
|
||||
WORKFLOW_ACTIVE_STATUSES,
|
||||
WORKFLOW_TERMINAL_STATUSES,
|
||||
HOST_DECISION_OUTCOMES,
|
||||
VCS_KINDS,
|
||||
} from "./workflow-types.js";
|
||||
|
||||
export {
|
||||
@@ -160,3 +170,17 @@ export type {
|
||||
ChildRunResult,
|
||||
StreamProgress,
|
||||
} from "./child-process.js";
|
||||
|
||||
export {
|
||||
VcsError,
|
||||
GitProvider,
|
||||
SvnProvider,
|
||||
detectVcs,
|
||||
createVcsProvider,
|
||||
extractBaselineHead,
|
||||
findVcsCandidates,
|
||||
} from "./vcs-provider.js";
|
||||
|
||||
export type {
|
||||
VcsProvider,
|
||||
} from "./vcs-provider.js";
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs";
|
||||
export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort" | "logs" | "extend";
|
||||
|
||||
export interface WorkflowStartArgs {
|
||||
sub: "start";
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
planInput: string; // file path or "-"
|
||||
maxCycles?: number;
|
||||
}
|
||||
@@ -51,6 +52,12 @@ export interface WorkflowLogsArgs {
|
||||
tail: number;
|
||||
}
|
||||
|
||||
export interface WorkflowExtendArgs {
|
||||
sub: "extend";
|
||||
runId: string;
|
||||
additionalCycles: number;
|
||||
}
|
||||
|
||||
export type WorkflowArgs =
|
||||
| WorkflowStartArgs
|
||||
| WorkflowReviewArgs
|
||||
@@ -59,7 +66,8 @@ export type WorkflowArgs =
|
||||
| WorkflowDecideArgs
|
||||
| WorkflowStatusArgs
|
||||
| WorkflowAbortArgs
|
||||
| WorkflowLogsArgs;
|
||||
| WorkflowLogsArgs
|
||||
| WorkflowExtendArgs;
|
||||
|
||||
export class WorkflowArgsError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -85,11 +93,12 @@ function parsePositiveInt(flag: string, value: string): number {
|
||||
|
||||
const WORKFLOW_USAGE = `
|
||||
Usage:
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--max-cycles <n>]
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn>] [--max-cycles <n>]
|
||||
agent-workflow review --run <run-id>
|
||||
agent-workflow retry-review --run <run-id>
|
||||
agent-workflow retry-execute --run <run-id>
|
||||
agent-workflow decide --run <run-id> --input <decision.json|->
|
||||
agent-workflow extend --run <run-id> --additional-cycles <n>
|
||||
agent-workflow status --run <run-id> [--json]
|
||||
agent-workflow abort --run <run-id> --reason <text>
|
||||
agent-workflow logs --run <run-id> [--follow] [--tail <lines>]
|
||||
@@ -98,12 +107,14 @@ Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 fo
|
||||
|
||||
Examples:
|
||||
agent-workflow start --plan plan.json --executor reasonix
|
||||
agent-workflow start --plan plan.json --executor reasonix --vcs svn
|
||||
agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin
|
||||
agent-workflow review --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123
|
||||
agent-workflow decide --run <id> --input decision.json
|
||||
agent-workflow decide --run <id> --input - # read decision from stdin
|
||||
agent-workflow extend --run <id> --additional-cycles 3
|
||||
agent-workflow status --run <id>
|
||||
agent-workflow status --run <id> --json
|
||||
agent-workflow abort --run <id> --reason "changed requirements"
|
||||
@@ -125,6 +136,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
switch (sub) {
|
||||
case "start": {
|
||||
let executor: string | undefined;
|
||||
let vcs: string | undefined;
|
||||
let planInput: string | undefined;
|
||||
let maxCycles: number | undefined;
|
||||
|
||||
@@ -134,6 +146,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
case "--executor":
|
||||
executor = requireValue(arg, rest);
|
||||
break;
|
||||
case "--vcs":
|
||||
vcs = requireValue(arg, rest);
|
||||
break;
|
||||
case "--plan":
|
||||
planInput = requireValue(arg, rest);
|
||||
break;
|
||||
@@ -151,7 +166,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
|
||||
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
|
||||
|
||||
return { sub: "start", executor, planInput, maxCycles };
|
||||
return { sub: "start", executor, vcs, planInput, maxCycles };
|
||||
}
|
||||
|
||||
case "review": {
|
||||
@@ -283,6 +298,28 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
return { sub: "logs", runId, follow, tail };
|
||||
}
|
||||
|
||||
case "extend": {
|
||||
let runId: string | undefined;
|
||||
let additionalCycles: number | undefined;
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
if (arg === "--run") {
|
||||
runId = requireValue(arg, rest);
|
||||
} else if (arg === "--additional-cycles") {
|
||||
additionalCycles = parsePositiveInt(arg, requireValue(arg, rest));
|
||||
} else if (arg === "-h" || arg === "--help") {
|
||||
workflowUsage(0);
|
||||
} else {
|
||||
throw new WorkflowArgsError(`Unknown option: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!runId) throw new WorkflowArgsError("--run is required for workflow extend");
|
||||
if (additionalCycles === undefined) {
|
||||
throw new WorkflowArgsError("--additional-cycles is required for workflow extend");
|
||||
}
|
||||
return { sub: "extend", runId, additionalCycles };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`);
|
||||
}
|
||||
|
||||
+22
-1
@@ -12,8 +12,9 @@ import type {
|
||||
ExecutorKind,
|
||||
ResolvedWorkflowConfig,
|
||||
WorkflowProjectConfig,
|
||||
VcsKind,
|
||||
} from "./workflow-types.js";
|
||||
import { EXECUTOR_KINDS } from "./workflow-types.js";
|
||||
import { EXECUTOR_KINDS, VCS_KINDS } from "./workflow-types.js";
|
||||
|
||||
const WORKFLOW_CONFIG_FILE = "agent-workflow.json";
|
||||
|
||||
@@ -54,6 +55,12 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
|
||||
if (obj.defaultExecutor !== undefined && !EXECUTOR_KINDS.includes(obj.defaultExecutor as ExecutorKind)) {
|
||||
throw new WorkflowConfigError(`${filePath}: defaultExecutor must be one of ${EXECUTOR_KINDS.join(", ")}`);
|
||||
}
|
||||
if (obj.defaultVcs !== undefined) {
|
||||
const validVcs = ["auto", ...VCS_KINDS];
|
||||
if (!validVcs.includes(obj.defaultVcs as string)) {
|
||||
throw new WorkflowConfigError(`${filePath}: defaultVcs must be one of ${validVcs.join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (obj.maxCycles !== undefined && (typeof obj.maxCycles !== "number" || !Number.isInteger(obj.maxCycles) || obj.maxCycles < 1)) {
|
||||
throw new WorkflowConfigError(`${filePath}: maxCycles must be a positive integer`);
|
||||
}
|
||||
@@ -73,6 +80,7 @@ function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectC
|
||||
|
||||
export interface WorkflowCliOverrides {
|
||||
executor?: ExecutorKind;
|
||||
vcs?: VcsKind;
|
||||
maxCycles?: number;
|
||||
}
|
||||
|
||||
@@ -88,6 +96,18 @@ export function resolveWorkflowConfig(
|
||||
throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`);
|
||||
}
|
||||
|
||||
// VCS selection precedence: CLI --vcs, then project defaultVcs (unless "auto"), then auto-detect
|
||||
let vcs: VcsKind | undefined;
|
||||
if (cli.vcs) {
|
||||
if (!VCS_KINDS.includes(cli.vcs)) {
|
||||
throw new WorkflowConfigError(`Unknown VCS: ${cli.vcs}. Must be one of: ${VCS_KINDS.join(", ")}`);
|
||||
}
|
||||
vcs = cli.vcs;
|
||||
} else if (project?.defaultVcs && project.defaultVcs !== "auto") {
|
||||
vcs = project.defaultVcs as VcsKind;
|
||||
}
|
||||
// If vcs is still undefined, caller will auto-detect
|
||||
|
||||
const maxCycles = cli.maxCycles ?? project?.maxCycles ?? DEFAULTS.maxCycles;
|
||||
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
|
||||
throw new WorkflowConfigError(`maxCycles must be a positive integer, got: ${maxCycles}`);
|
||||
@@ -101,6 +121,7 @@ export function resolveWorkflowConfig(
|
||||
|
||||
return {
|
||||
executor,
|
||||
...(vcs ? { vcs } : {}),
|
||||
maxCycles,
|
||||
timeoutSeconds,
|
||||
executorConfig,
|
||||
|
||||
+425
-55
@@ -5,6 +5,7 @@
|
||||
* review — generates a review bundle for the Codex host
|
||||
* retry-review — resumes a cleaned scope-gate review
|
||||
* decide — receives Codex HostDecisionV1, drives state transitions
|
||||
* extend — extends a budget_exhausted run with additional cycles
|
||||
* status — prints current run state
|
||||
* abort — terminates a run with a reason
|
||||
*
|
||||
@@ -19,7 +20,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan } from "./workflow-config.js";
|
||||
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan, WorkflowConfigError } from "./workflow-config.js";
|
||||
import { checkConvergence, decisionSignatures } from "./workflow-convergence.js";
|
||||
import type { FindingSignature } from "./workflow-convergence.js";
|
||||
import { createExecutorAdapter, probeExecutor } from "./workflow-executor.js";
|
||||
@@ -27,17 +28,12 @@ import {
|
||||
WorkflowLockError,
|
||||
acquireLock,
|
||||
appendEvent,
|
||||
checkFilesInScope,
|
||||
cycleDiffFile,
|
||||
cycleDecisionFile,
|
||||
cycleExecLogFile,
|
||||
cycleHostReviewFile,
|
||||
findRunDir,
|
||||
generateRunId,
|
||||
gitDiffFromHead,
|
||||
gitHead,
|
||||
gitIsClean,
|
||||
gitRepoRoot,
|
||||
initWorkflowState,
|
||||
nowIso,
|
||||
readState,
|
||||
@@ -46,12 +42,20 @@ import {
|
||||
runDir as makeRunDir,
|
||||
writeState,
|
||||
} from "./workflow-state.js";
|
||||
import {
|
||||
createVcsProvider,
|
||||
detectVcs,
|
||||
extractBaselineHead,
|
||||
VcsError,
|
||||
} from "./vcs-provider.js";
|
||||
import type {
|
||||
CycleRecord,
|
||||
ExecutorResult,
|
||||
HostDecisionV1,
|
||||
HostReviewBundleV1,
|
||||
ScopeViolation,
|
||||
VcsBaseline,
|
||||
VcsKind,
|
||||
WorkflowPlanV1,
|
||||
WorkflowState,
|
||||
WorkflowStatusOutput,
|
||||
@@ -237,22 +241,101 @@ function persistExecutorAttempt(
|
||||
export interface StartWorkflowOpts {
|
||||
planInput: string; // file path or "-" for stdin
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
maxCycles?: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** Resolve VCS selection from candidates and configs - exported for testing */
|
||||
export async function resolveVcsSelection(
|
||||
cwd: string,
|
||||
explicitVcs?: VcsKind
|
||||
): Promise<{ vcsKind: VcsKind; repoRoot: string }> {
|
||||
// If explicit VCS provided, use it directly
|
||||
if (explicitVcs) {
|
||||
const provider = createVcsProvider(explicitVcs);
|
||||
try {
|
||||
const repoRoot = provider.repoRoot(cwd);
|
||||
return { vcsKind: explicitVcs, repoRoot };
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Discover candidates
|
||||
const { findVcsCandidates } = await import("./vcs-provider.js");
|
||||
const candidates: { git?: string; svn?: string } = findVcsCandidates(cwd);
|
||||
|
||||
if (!candidates.git && !candidates.svn) {
|
||||
throw new WorkflowEngineError(
|
||||
"No supported VCS detected. Repository must be a Git repository or SVN working copy."
|
||||
);
|
||||
}
|
||||
|
||||
// Load config from every distinct candidate root
|
||||
const configs = new Map<string, { vcs?: VcsKind; root: string }>();
|
||||
|
||||
for (const [vcsType, root] of Object.entries(candidates) as Array<[string, string | undefined]>) {
|
||||
if (!root) continue;
|
||||
|
||||
// Skip if we already loaded config from this root
|
||||
if (configs.has(root)) continue;
|
||||
|
||||
try {
|
||||
const config = resolveWorkflowConfig(root, {});
|
||||
configs.set(root, { vcs: config.vcs, root });
|
||||
} catch (err) {
|
||||
// Re-throw validation errors, tolerate missing config
|
||||
if (err instanceof WorkflowConfigError) {
|
||||
throw new WorkflowEngineError(`Invalid agent-workflow.json at ${root}: ${(err as WorkflowConfigError).message}`);
|
||||
}
|
||||
// Config doesn't exist - that's ok
|
||||
configs.set(root, { root });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract project defaultVcs settings
|
||||
const projectDefaults = Array.from(configs.values())
|
||||
.map(c => c.vcs)
|
||||
.filter((v): v is VcsKind => v !== undefined);
|
||||
|
||||
// Check for conflicting project defaults
|
||||
const uniqueDefaults = new Set(projectDefaults);
|
||||
if (uniqueDefaults.size > 1) {
|
||||
throw new WorkflowEngineError(
|
||||
`Conflicting VCS defaults in project configs: ${Array.from(uniqueDefaults).join(" vs ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply resolution: CLI > unambiguous project defaultVcs > single candidate > error
|
||||
const projectDefault = projectDefaults[0];
|
||||
|
||||
if (candidates.git && candidates.svn) {
|
||||
// Ambiguous: need explicit selection
|
||||
if (projectDefault === "git") {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
} else if (projectDefault === "svn") {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn };
|
||||
} else {
|
||||
throw new WorkflowEngineError(
|
||||
"Ambiguous VCS: both Git and SVN detected. " +
|
||||
"Use --vcs git or --vcs svn to explicitly select, or set defaultVcs in agent-workflow.json."
|
||||
);
|
||||
}
|
||||
} else if (candidates.git) {
|
||||
return { vcsKind: "git", repoRoot: candidates.git };
|
||||
} else {
|
||||
return { vcsKind: "svn", repoRoot: candidates.svn! };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: string }> {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
|
||||
// 1. Locate repo root
|
||||
let repoRoot: string;
|
||||
try {
|
||||
repoRoot = gitRepoRoot(cwd);
|
||||
} catch (err) {
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
// 2. Load and validate plan
|
||||
// 1. Load and validate plan first
|
||||
let planRaw: string;
|
||||
if (opts.planInput === "-") {
|
||||
planRaw = fs.readFileSync(0, "utf8");
|
||||
@@ -272,20 +355,29 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
validateWorkflowPlan(plan);
|
||||
const typedPlan = plan as WorkflowPlanV1;
|
||||
|
||||
// 3. Resolve config
|
||||
// 2. Resolve VCS selection using deterministic helper
|
||||
const { vcsKind, repoRoot } = await resolveVcsSelection(cwd, opts.vcs as VcsKind | undefined);
|
||||
|
||||
// 3. Resolve full config from final repoRoot
|
||||
const config = resolveWorkflowConfig(repoRoot, {
|
||||
...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}),
|
||||
...(opts.vcs ? { vcs: opts.vcs as VcsKind } : {}),
|
||||
...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}),
|
||||
});
|
||||
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
|
||||
// 4. Probe executor
|
||||
await probeExecutor(config.executor, config.executorConfig);
|
||||
|
||||
// 5. Require clean working tree
|
||||
let isClean: boolean;
|
||||
try {
|
||||
isClean = gitIsClean(repoRoot);
|
||||
isClean = vcsProvider.isClean(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
if (!isClean) {
|
||||
@@ -295,7 +387,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Acquire lock (one active workflow per repo)
|
||||
// 6. Capture baseline
|
||||
let vcsBaseline: VcsBaseline;
|
||||
try {
|
||||
vcsBaseline = vcsProvider.captureBaseline(repoRoot);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
}
|
||||
|
||||
// 7. Acquire lock (one active workflow per repo)
|
||||
const runId = generateRunId();
|
||||
try {
|
||||
acquireLock(repoRoot, runId);
|
||||
@@ -309,11 +412,11 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
const dir = makeRunDir(repoRoot, runId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const baselineHead = gitHead(repoRoot);
|
||||
const state = initWorkflowState({
|
||||
runId,
|
||||
repoRoot,
|
||||
baselineHead,
|
||||
vcsBaseline,
|
||||
vcsKind,
|
||||
executor: config.executor,
|
||||
plan: typedPlan,
|
||||
maxCycles: config.maxCycles,
|
||||
@@ -327,13 +430,18 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
kind: "workflow_started",
|
||||
runId,
|
||||
timestamp: state.createdAt,
|
||||
data: { executor: config.executor, maxCycles: config.maxCycles, baselineHead },
|
||||
data: { executor: config.executor, maxCycles: config.maxCycles, vcsKind, vcsBaseline },
|
||||
});
|
||||
|
||||
process.stdout.write(`workflow started: ${runId}\n`);
|
||||
process.stdout.write(` vcs: ${vcsKind}\n`);
|
||||
process.stdout.write(` executor: ${config.executor}\n`);
|
||||
process.stdout.write(` max-cycles: ${config.maxCycles}\n`);
|
||||
process.stdout.write(` baseline: ${baselineHead}\n`);
|
||||
if (vcsBaseline.kind === "git") {
|
||||
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
||||
} else if (vcsBaseline.kind === "svn") {
|
||||
process.stdout.write(` baseline: r${vcsBaseline.revision}\n`);
|
||||
}
|
||||
process.stdout.write(` run dir: ${dir}\n\n`);
|
||||
|
||||
// 7. Start first execution cycle
|
||||
@@ -441,33 +549,56 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
state.enginePid = process.pid;
|
||||
writeStateSafety(dir, state);
|
||||
|
||||
// 1. Generate diff from baseline HEAD
|
||||
// Get VCS provider
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state (corrupt state file).");
|
||||
}
|
||||
|
||||
// 1. Generate diff from baseline
|
||||
let diff: string;
|
||||
try {
|
||||
diff = gitDiffFromHead(state.repoRoot, state.baselineHead);
|
||||
diff = vcsProvider.diffFromBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${(err as Error).message}`);
|
||||
throw new WorkflowEngineError((err as Error).message);
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${err.message}`);
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const diffFile = cycleDiffFile(dir, cycleIndex);
|
||||
fs.writeFileSync(diffFile, diff, "utf8");
|
||||
cycleRecord.diffFile = path.relative(dir, diffFile);
|
||||
|
||||
// 2. Check for HEAD drift (executor committed something)
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
// Check if intermediate commits happened
|
||||
// 2. Check for baseline drift
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(
|
||||
state,
|
||||
dir,
|
||||
"blocked",
|
||||
`HEAD changed from baseline (${state.baselineHead.slice(0, 8)}) to ${currentHead.slice(0, 8)} — executor may have committed. Manual review required.`,
|
||||
`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
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
transitionToTerminal(state, dir, "blocked", `Failed to check scope: ${err.message}`);
|
||||
throw new WorkflowEngineError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (outOfScope.length > 0) {
|
||||
const scopeViolations = makeScopeViolations(outOfScope);
|
||||
const previousCycle = state.cycles.find((cycle) => cycle.cycleIndex === cycleIndex - 1);
|
||||
@@ -521,7 +652,9 @@ export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise<void> {
|
||||
runId: state.runId,
|
||||
cycleIndex,
|
||||
repoRoot: state.repoRoot,
|
||||
baselineHead: state.baselineHead,
|
||||
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
|
||||
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
|
||||
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
|
||||
generatedAt,
|
||||
plan: state.plan,
|
||||
diffFile: diffFile,
|
||||
@@ -846,14 +979,33 @@ function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex:
|
||||
return "Codex review bundle is missing from disk.";
|
||||
}
|
||||
|
||||
// 2. HEAD must not have changed
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
return `HEAD changed (baseline: ${state.baselineHead.slice(0, 8)}, current: ${currentHead.slice(0, 8)}).`;
|
||||
// 2. Baseline must not have changed
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
return "No baseline found in state.";
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return err.message;
|
||||
}
|
||||
return `Baseline validation failed: ${(err as Error).message}`;
|
||||
}
|
||||
|
||||
// 3. All files must be in scope
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
return `Scope check failed: ${err.message}`;
|
||||
}
|
||||
return `Scope check failed: ${(err as Error).message}`;
|
||||
}
|
||||
if (outOfScope.length > 0) {
|
||||
return `Out-of-scope files: ${outOfScope.join(", ")}`;
|
||||
}
|
||||
@@ -961,15 +1113,32 @@ export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void {
|
||||
);
|
||||
}
|
||||
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry review: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
|
||||
4,
|
||||
);
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state.");
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
if (outOfScope.length > 0) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry review while out-of-scope files remain: ${outOfScope.sort().join(", ")}.`,
|
||||
@@ -1052,14 +1221,31 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
}
|
||||
const resolvingScope = !recoveringInitialCycle && (previousCycle?.scopeViolations?.length ?? 0) > 0;
|
||||
|
||||
const currentHead = gitHead(state.repoRoot);
|
||||
if (currentHead !== state.baselineHead) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot retry execution: HEAD changed from baseline ${state.baselineHead.slice(0, 8)} to ${currentHead.slice(0, 8)}.`,
|
||||
4,
|
||||
);
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
throw new WorkflowEngineError("No baseline found in state.", 4);
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let outOfScope: string[];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
if (err instanceof VcsError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope);
|
||||
const approvedScopePaths = new Set((previousCycle?.scopeViolations ?? []).map((violation) => violation.path));
|
||||
const unexpectedOutOfScope = resolvingScope
|
||||
? outOfScope.filter((filePath) => !approvedScopePaths.has(filePath))
|
||||
@@ -1128,6 +1314,180 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
await resumeExecutorCycle(state, dir, cycle, acceptedFindings);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflow extend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExtendWorkflowOpts {
|
||||
runId: string;
|
||||
additionalCycles: number;
|
||||
}
|
||||
|
||||
export async function extendWorkflow(opts: ExtendWorkflowOpts): Promise<void> {
|
||||
// 1. Validate positive integer
|
||||
if (!Number.isInteger(opts.additionalCycles) || opts.additionalCycles < 1) {
|
||||
throw new WorkflowEngineError(
|
||||
`--additional-cycles must be a positive integer, got: ${opts.additionalCycles}`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
const { state, dir } = requireState(opts.runId);
|
||||
|
||||
// 2. Must be in budget_exhausted terminal state
|
||||
if (state.status !== "budget_exhausted") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' is in status '${state.status}', not budget_exhausted. ` +
|
||||
`Only budget_exhausted runs can be extended.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Must have a valid session handle
|
||||
if (!state.sessionHandle) {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' has no session handle. Cannot extend without a resumable session.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. The last persisted decision must be 'fix'
|
||||
const lastCycle = state.cycles[state.cycles.length - 1];
|
||||
if (!lastCycle?.decisionFile || lastCycle.decisionOutcome !== "fix") {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' cannot be extended: the last cycle decision must be 'fix'.`,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Reacquire the repository lock BEFORE validation
|
||||
try {
|
||||
acquireLock(state.repoRoot, state.runId);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowLockError) {
|
||||
throw new WorkflowEngineError(err.message, 4);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Helper to release lock and throw
|
||||
const failAndRelease = (message: string, exitCode = 4): never => {
|
||||
releaseLock(state.repoRoot, state.runId);
|
||||
throw new WorkflowEngineError(message, exitCode);
|
||||
};
|
||||
|
||||
// 6. Validate baseline and scope under the lock
|
||||
const vcsKind = state.vcsKind || "git";
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const baseline = state.vcsBaseline || (state.baselineHead ? { kind: "git" as const, head: state.baselineHead } : undefined);
|
||||
if (!baseline) {
|
||||
failAndRelease("No baseline found in state.");
|
||||
return; // Never reached but helps TypeScript
|
||||
}
|
||||
|
||||
try {
|
||||
vcsProvider.validateBaseline(state.repoRoot, baseline);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: baseline validation failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let outOfScope: string[] = [];
|
||||
try {
|
||||
outOfScope = vcsProvider.checkFilesInScope(state.repoRoot, baseline, state.plan.scope);
|
||||
} catch (err) {
|
||||
const message = err instanceof VcsError ? err.message : (err as Error).message;
|
||||
failAndRelease(`Cannot extend: scope check failed: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outOfScope.length > 0) {
|
||||
failAndRelease(`Cannot extend: out-of-scope files detected: ${outOfScope.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Check convergence using existing history
|
||||
const convergenceError = checkWorkflowConvergence(state, dir);
|
||||
if (convergenceError) {
|
||||
failAndRelease(`Cannot extend: ${convergenceError}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Load and validate the last decision BEFORE mutating state
|
||||
const decisionPath = path.join(dir, lastCycle.decisionFile!);
|
||||
let decision: HostDecisionV1;
|
||||
let acceptedFindings: string = "";
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown;
|
||||
validateHostDecision(raw);
|
||||
decision = raw;
|
||||
acceptedFindings = buildAcceptedFindingsText(decision, state, dir, lastCycle.cycleIndex);
|
||||
} catch (err) {
|
||||
failAndRelease(`Cannot extend: failed to read last decision: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 9. Now atomically mutate state: budget, cycle, status, history
|
||||
const oldMaxCycles = state.maxCycles;
|
||||
const newMaxCycles = oldMaxCycles + opts.additionalCycles;
|
||||
const nextCycle = state.currentCycle + 1;
|
||||
const extendedAt = nowIso();
|
||||
|
||||
state.maxCycles = newMaxCycles;
|
||||
state.enginePid = process.pid;
|
||||
state.updatedAt = extendedAt;
|
||||
state.status = "executing";
|
||||
delete state.stopReason;
|
||||
delete state.stopDescription;
|
||||
|
||||
const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: extendedAt };
|
||||
state.cycles.push(nextCycleRecord);
|
||||
state.currentCycle = nextCycle;
|
||||
|
||||
// Record extension history in state
|
||||
if (!state.extensionHistory) {
|
||||
state.extensionHistory = [];
|
||||
}
|
||||
state.extensionHistory.push({
|
||||
extendedAt,
|
||||
oldMaxCycles,
|
||||
newMaxCycles,
|
||||
additionalCycles: opts.additionalCycles,
|
||||
resumedCycle: nextCycle,
|
||||
});
|
||||
|
||||
// 10. Persist state and events
|
||||
writeState(dir, state);
|
||||
appendEvent(dir, {
|
||||
kind: "budget_extended",
|
||||
runId: state.runId,
|
||||
timestamp: extendedAt,
|
||||
data: {
|
||||
additionalCycles: opts.additionalCycles,
|
||||
oldMaxCycles,
|
||||
newMaxCycles,
|
||||
nextCycle,
|
||||
},
|
||||
});
|
||||
|
||||
appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle });
|
||||
|
||||
process.stdout.write(`workflow extended: ${state.runId}\n`);
|
||||
process.stdout.write(` additional cycles: ${opts.additionalCycles}\n`);
|
||||
process.stdout.write(` old max cycles: ${oldMaxCycles}\n`);
|
||||
process.stdout.write(` new max cycles: ${newMaxCycles}\n`);
|
||||
process.stdout.write(` resuming cycle: ${nextCycle}\n\n`);
|
||||
|
||||
// 11. Resume the executor session immediately (lock released by resumeExecutorCycle on completion)
|
||||
try {
|
||||
await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings);
|
||||
} catch (err) {
|
||||
// resumeExecutorCycle already handles errors and releases lock
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflow status
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1177,7 +1537,9 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
scopeViolations: state.status === "awaiting_scope_resolution" ? cycle?.scopeViolations : undefined,
|
||||
stopReason: state.stopReason,
|
||||
stopDescription: state.stopDescription,
|
||||
baselineHead: state.baselineHead,
|
||||
...(state.baselineHead ? { baselineHead: state.baselineHead } : {}),
|
||||
...(state.vcsBaseline ? { vcsBaseline: state.vcsBaseline } : {}),
|
||||
...(state.vcsKind ? { vcsKind: state.vcsKind } : {}),
|
||||
repoRoot: state.repoRoot,
|
||||
createdAt: state.createdAt,
|
||||
updatedAt: state.updatedAt,
|
||||
@@ -1230,7 +1592,15 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
if (state.stopReason) lines.push(p("Stop reason", state.stopReason));
|
||||
if (state.stopDescription) lines.push(p("Stop detail", state.stopDescription.slice(0, 80)));
|
||||
lines.push(p("Repo root", state.repoRoot));
|
||||
lines.push(p("Baseline HEAD", state.baselineHead.slice(0, 8)));
|
||||
const baselineHead = extractBaselineHead(state);
|
||||
if (baselineHead) {
|
||||
lines.push(p("Baseline HEAD", baselineHead.slice(0, 8)));
|
||||
} else if (state.vcsBaseline?.kind === "svn") {
|
||||
lines.push(p("Baseline SVN", `r${state.vcsBaseline.revision}`));
|
||||
}
|
||||
if (state.vcsKind) {
|
||||
lines.push(p("VCS", state.vcsKind));
|
||||
}
|
||||
lines.push(p("Created", state.createdAt));
|
||||
lines.push(p("Updated", state.updatedAt));
|
||||
lines.push("─".repeat(46));
|
||||
|
||||
@@ -41,13 +41,15 @@ const SAFETY_CONSTRAINTS = `
|
||||
## CRITICAL CONSTRAINTS — DO NOT VIOLATE
|
||||
|
||||
- DO NOT run git commit, git push, or git reset under any circumstances.
|
||||
- DO NOT run svn commit, svn switch, svn update, svn relocate, or svn revert under any circumstances.
|
||||
- DO NOT modify .svn metadata directories or files.
|
||||
- DO NOT rewrite git history or delete branches.
|
||||
- DO NOT create or modify files outside the scope listed above.
|
||||
- Exception: a Scope Remediation prompt may authorize removing or restoring only the exact out-of-scope paths it lists.
|
||||
- Put all temporary files in an OS temporary directory outside the repository (for example, one created with mktemp).
|
||||
- If you accidentally create a temporary repository file, remove only that artifact before exiting.
|
||||
- SQLite cleanup must include the database and its -wal, -shm, and -journal sidecars.
|
||||
- Before exiting, inspect git status --porcelain --untracked-files=all and leave no artifacts outside the allowed scope.
|
||||
- Before exiting, inspect git status --porcelain --untracked-files=all (or svn status) and leave no artifacts outside the allowed scope.
|
||||
- You may read, edit, and test code, but never commit or push changes.
|
||||
- If you encounter an error you cannot resolve, stop and report it clearly.
|
||||
`.trimStart();
|
||||
|
||||
+24
-104
@@ -17,8 +17,10 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import type { ProgressSidecar, WorkflowEvent, WorkflowState } from "./workflow-types.js";
|
||||
import type { ProgressSidecar, WorkflowEvent, WorkflowState, VcsBaseline } from "./workflow-types.js";
|
||||
|
||||
// Import VCS providers for backward-compatible helpers
|
||||
import { GitProvider } from "./vcs-provider.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
@@ -160,7 +162,7 @@ export function readLock(repoRoot: string): string | undefined {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git helpers
|
||||
// Legacy Git helpers (kept for backward compatibility with old code)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class GitError extends Error {
|
||||
@@ -170,118 +172,32 @@ export class GitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the absolute repo root (throws if not in a git repo). */
|
||||
const _gitProvider = new GitProvider();
|
||||
|
||||
/** @deprecated Use VcsProvider instead. Return the absolute repo root (throws if not in a git repo). */
|
||||
export function gitRepoRoot(cwd: string = process.cwd()): string {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
throw new GitError("Not in a git repository (or git not found).");
|
||||
}
|
||||
return _gitProvider.repoRoot(cwd);
|
||||
}
|
||||
|
||||
/** Return HEAD SHA. */
|
||||
/** @deprecated Use VcsProvider instead. Return HEAD SHA. */
|
||||
export function gitHead(repoRoot: string): string {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch {
|
||||
throw new GitError("Failed to read HEAD.");
|
||||
}
|
||||
const baseline = _gitProvider.captureBaseline(repoRoot);
|
||||
return baseline.kind === "git" ? baseline.head : "";
|
||||
}
|
||||
|
||||
/** Return true when the working tree is clean (no uncommitted changes). */
|
||||
/** @deprecated Use VcsProvider instead. Return true when the working tree is clean (no uncommitted changes). */
|
||||
export function gitIsClean(repoRoot: string): boolean {
|
||||
try {
|
||||
const status = execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
return status === "";
|
||||
} catch {
|
||||
throw new GitError("Failed to check git status.");
|
||||
}
|
||||
return _gitProvider.isClean(repoRoot);
|
||||
}
|
||||
|
||||
/** Generate a diff from baseline HEAD to the current working tree. */
|
||||
/** @deprecated Use VcsProvider instead. Generate a diff from baseline HEAD to the current working tree. */
|
||||
export function gitDiffFromHead(repoRoot: string, baselineHead: string): string {
|
||||
try {
|
||||
const trackedDiff = execFileSync("git", ["diff", baselineHead], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
const untrackedStr = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const untrackedFiles = untrackedStr
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
let finalDiff = trackedDiff;
|
||||
for (const file of untrackedFiles) {
|
||||
const res = spawnSync("git", ["diff", "--no-index", "/dev/null", file], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const untrackedDiff = (res.stdout || "").trim();
|
||||
if (untrackedDiff) {
|
||||
if (finalDiff) {
|
||||
finalDiff += "\n" + untrackedDiff;
|
||||
} else {
|
||||
finalDiff = untrackedDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
return finalDiff;
|
||||
} catch {
|
||||
throw new GitError("Failed to compute diff from baseline HEAD.");
|
||||
}
|
||||
return _gitProvider.diffFromBaseline(repoRoot, { kind: "git", head: baselineHead });
|
||||
}
|
||||
|
||||
/** Check that all modified files are within the allowed scope. */
|
||||
/** @deprecated Use VcsProvider instead. Check that all modified files are within the allowed scope. */
|
||||
export function checkFilesInScope(repoRoot: string, baselineHead: string, scope: string[]): string[] {
|
||||
let diff: string;
|
||||
let untracked: string;
|
||||
try {
|
||||
diff = execFileSync("git", ["diff", "--name-only", baselineHead], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
throw new GitError("Failed to check modified files.");
|
||||
}
|
||||
|
||||
const modifiedFiles = (diff + "\n" + untracked)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const outOfScope: string[] = [];
|
||||
for (const file of modifiedFiles) {
|
||||
const inScope = scope.some((s) => {
|
||||
const normalized = s.replace(/\/+$/, "");
|
||||
return file === normalized || file.startsWith(normalized + "/");
|
||||
});
|
||||
if (!inScope) outOfScope.push(file);
|
||||
}
|
||||
return outOfScope;
|
||||
return _gitProvider.checkFilesInScope(repoRoot, { kind: "git", head: baselineHead }, scope);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -296,7 +212,9 @@ export function nowIso(): string {
|
||||
export function initWorkflowState(opts: {
|
||||
runId: string;
|
||||
repoRoot: string;
|
||||
baselineHead: string;
|
||||
baselineHead?: string;
|
||||
vcsBaseline?: VcsBaseline;
|
||||
vcsKind?: import("./workflow-types.js").VcsKind;
|
||||
executor: import("./workflow-types.js").ExecutorKind;
|
||||
plan: import("./workflow-types.js").WorkflowPlanV1;
|
||||
maxCycles: number;
|
||||
@@ -308,7 +226,9 @@ export function initWorkflowState(opts: {
|
||||
runId: opts.runId,
|
||||
repoHash: repoHash(opts.repoRoot),
|
||||
repoRoot: opts.repoRoot,
|
||||
baselineHead: opts.baselineHead,
|
||||
...(opts.baselineHead ? { baselineHead: opts.baselineHead } : {}),
|
||||
...(opts.vcsBaseline ? { vcsBaseline: opts.vcsBaseline } : {}),
|
||||
...(opts.vcsKind ? { vcsKind: opts.vcsKind } : {}),
|
||||
executor: opts.executor,
|
||||
plan: opts.plan,
|
||||
status: "executing",
|
||||
|
||||
@@ -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 type ExecutorKind = (typeof EXECUTOR_KINDS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VCS abstraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VCS_KINDS = ["git", "svn"] as const;
|
||||
export type VcsKind = (typeof VCS_KINDS)[number];
|
||||
|
||||
/** Structured VCS baseline (replaces single baselineHead string). */
|
||||
export interface VcsBaselineGit {
|
||||
kind: "git";
|
||||
head: string;
|
||||
}
|
||||
|
||||
export interface VcsBaselineSvn {
|
||||
kind: "svn";
|
||||
/** Repository root URL */
|
||||
repoUrl: string;
|
||||
/** Repository UUID */
|
||||
repoUuid: string;
|
||||
/** Single working revision (mixed revisions are rejected) */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export type VcsBaseline = VcsBaselineGit | VcsBaselineSvn;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress sidecar — lightweight real-time observability
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -167,8 +192,12 @@ export interface WorkflowState {
|
||||
repoHash: string;
|
||||
/** Absolute path to the repository root. */
|
||||
repoRoot: string;
|
||||
/** Git HEAD SHA at workflow start. */
|
||||
baselineHead: string;
|
||||
/** Git HEAD SHA at workflow start (version 1 states only). */
|
||||
baselineHead?: string;
|
||||
/** Structured VCS baseline (replaces baselineHead for new runs). */
|
||||
vcsBaseline?: VcsBaseline;
|
||||
/** VCS kind detected or specified at start. */
|
||||
vcsKind?: VcsKind;
|
||||
executor: ExecutorKind;
|
||||
executorConfig?: ExecutorConfig;
|
||||
timeoutSeconds?: number;
|
||||
@@ -187,6 +216,16 @@ export interface WorkflowState {
|
||||
updatedAt: string;
|
||||
/** True when --agy-degraded-continue is in effect. */
|
||||
agyDegraded?: boolean;
|
||||
/** Extension history: records of each budget extension. */
|
||||
extensionHistory?: ExtensionRecord[];
|
||||
}
|
||||
|
||||
export interface ExtensionRecord {
|
||||
extendedAt: string;
|
||||
oldMaxCycles: number;
|
||||
newMaxCycles: number;
|
||||
additionalCycles: number;
|
||||
resumedCycle: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -230,7 +269,9 @@ export interface HostReviewBundleV1 {
|
||||
runId: string;
|
||||
cycleIndex: number;
|
||||
repoRoot: string;
|
||||
baselineHead: string;
|
||||
baselineHead?: string;
|
||||
vcsBaseline?: VcsBaseline;
|
||||
vcsKind?: VcsKind;
|
||||
generatedAt: string;
|
||||
plan: WorkflowPlanV1;
|
||||
diffFile: string;
|
||||
@@ -255,6 +296,7 @@ export interface ExecutorConfig {
|
||||
export interface WorkflowProjectConfig {
|
||||
version: "1";
|
||||
defaultExecutor?: ExecutorKind;
|
||||
defaultVcs?: "auto" | VcsKind;
|
||||
maxCycles?: number;
|
||||
timeoutSeconds?: number;
|
||||
executors?: {
|
||||
@@ -270,6 +312,7 @@ export interface WorkflowProjectConfig {
|
||||
|
||||
export interface ResolvedWorkflowConfig {
|
||||
executor: ExecutorKind;
|
||||
vcs?: VcsKind;
|
||||
maxCycles: number;
|
||||
timeoutSeconds: number;
|
||||
executorConfig: ExecutorConfig;
|
||||
@@ -295,7 +338,9 @@ export interface WorkflowStatusOutput {
|
||||
scopeViolations?: ScopeViolation[];
|
||||
stopReason?: WorkflowTerminalStatus;
|
||||
stopDescription?: string;
|
||||
baselineHead: string;
|
||||
baselineHead?: string;
|
||||
vcsBaseline?: VcsBaseline;
|
||||
vcsKind?: VcsKind;
|
||||
repoRoot: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -332,7 +377,8 @@ export type WorkflowEventKind =
|
||||
| "cycle_completed"
|
||||
| "workflow_stopped"
|
||||
| "workflow_aborted"
|
||||
| "blocked";
|
||||
| "blocked"
|
||||
| "budget_extended";
|
||||
|
||||
export interface WorkflowEvent {
|
||||
kind: WorkflowEventKind;
|
||||
|
||||
Reference in New Issue
Block a user