commit 35e8fa3717a605def0116674ea0322ea58043a30 Author: liujing Date: Thu Jul 16 20:56:24 2026 +0800 feat: initialize standalone agent-workflow diff --git a/.ai-commit.yaml b/.ai-commit.yaml new file mode 100644 index 0000000..bcfa342 --- /dev/null +++ b/.ai-commit.yaml @@ -0,0 +1,19 @@ +# Project-local ai-commit config (overrides ~/.ai-commit.yaml for this repo). +# Requires ai-commit >= v0.1.45 for commit.ai_footer. + +api: + model: ai-commit + timeout: 120s + +commit: + language: en + body_mode: auto + manual_ai_mode: off + ai_footer: off + template: "{type}({scope}): {subject}" + context: "" + triggers: [] + +ui: + spinner: auto + hook_spinner: off \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3d26add --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish to npm + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - run: npm ci + + - run: npm run build + + - run: npm run check + + - run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..371b071 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +npm-debug.log* +.DS_Store +*.tgz +.env +.env.* +!.env.example +.release-notes-*.md diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..b296b8c --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +# Final commit-msg stage (manual_ai_mode: off in .ai-commit.yaml — no AI-NONE footer). +set -e +command -v ai-commit >/dev/null 2>&1 || exit 0 +exec ai-commit commit-msg "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..5833e8e --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env sh +# Optional: preview AI message before commit (skipped in non-interactive / CI). +set -e +command -v ai-commit >/dev/null 2>&1 || exit 0 +exec ai-commit preview \ No newline at end of file diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 0000000..108e124 --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +# Fill commit message via ai-commit when using interactive git commit / placeholders. +# Requires ai-commit on PATH and .ai-commit.yaml in repo root. Silent skip if missing. +set -e +command -v ai-commit >/dev/null 2>&1 || exit 0 +exec ai-commit hook "$1" "$2" "${3:-}" \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5660f81 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..848376a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ZephyrDeng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f35bd83 --- /dev/null +++ b/NOTICE @@ -0,0 +1,43 @@ +agent-workflow + +Copyright (c) 2026 liujing + +This project contains code derived from ZephyrDeng/pi-review, +which is licensed under the MIT License. + +The workflow orchestration implementation (workflow-*.ts files) +was originally developed as part of pi-review and has been +adapted for agent-workflow with the following changes: + +- Renamed package from @zephyrdeng/pi-review to agent-workflow +- Changed CLI commands from "pi-review workflow " to "agent-workflow " +- Changed state root from ~/.pi/pi-review/workflows to ~/.agent-workflow/workflows +- Changed config file from pi-review.workflow.json to agent-workflow.json +- Changed metadata output from PI_WORKFLOW_META_JSON to AGENT_WORKFLOW_META_JSON +- Added legacy discovery and cross-root lock checking for pi-review compatibility +- Removed dependencies on pi-review specific types (ReviewFinding, FindingCluster) +- Removed Pi package manifest integration + +Original pi-review copyright notice: + +MIT License + +Copyright (c) 2026 ZephyrDeng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..44b4d4a --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# agent-workflow + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![Node.js](https://img.shields.io/badge/node-%3E%3D20-green.svg)](https://nodejs.org) + +**Codex-hosted autonomous implementation loop with Reasonix, Claude Code, and Agy executors.** + +`agent-workflow` orchestrates a stateful implement-review-fix cycle where: + +- **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 + +## Prerequisites + +- Node.js >= 20 +- Git repository +- At least one executor: [Reasonix](https://reasonix.ai), [Claude Code](https://claude.ai/code), or [Agy](https://agy.dev) + +## Installation + +```bash +npm install -g agent-workflow +``` + +Or use locally in a project: + +```bash +npm install --save-dev agent-workflow +``` + +## Quick Start + +1. Create a plan (WorkflowPlanV1 JSON): + +```json +{ + "version": "1", + "title": "Add user authentication", + "planMarkdown": "Implement JWT-based authentication with login and logout endpoints.", + "scope": ["src/auth/", "src/routes/auth.ts"], + "acceptanceCriteria": [ + "Login endpoint returns valid JWT", + "Protected routes reject unauthenticated requests", + "All tests pass" + ], + "verificationCommands": [ + ["npm", "test"], + ["npm", "run", "lint"] + ] +} +``` + +2. Start a workflow: + +```bash +agent-workflow start --plan plan.json --executor reasonix +``` + +3. Review the implementation: + +```bash +agent-workflow review --run +``` + +4. Submit a decision (fix, accept, or needs_human): + +```bash +agent-workflow decide --run --input decision.json +``` + +## Commands + +### `agent-workflow start` + +Start a new workflow run. Requires a clean working tree. + +```bash +agent-workflow start --plan --executor [--max-cycles ] +``` + +### `agent-workflow review` + +Generate a Codex review bundle after executor completion. Checks HEAD stability and scope compliance. + +```bash +agent-workflow review --run +``` + +### `agent-workflow decide` + +Submit a Codex decision (fix, accept, or needs_human). A fix decision resumes the same executor session. + +```bash +agent-workflow decide --run --input +``` + +### `agent-workflow retry-review` + +Retry review after the executor has removed approved out-of-scope artifacts. + +```bash +agent-workflow retry-review --run +``` + +### `agent-workflow retry-execute` + +Retry executor resume after clearing an external session conflict. + +```bash +agent-workflow retry-execute --run +``` + +### `agent-workflow status` + +Display current workflow status. + +```bash +agent-workflow status --run [--json] +``` + +### `agent-workflow abort` + +Terminate an active workflow. + +```bash +agent-workflow abort --run --reason "requirements changed" +``` + +## Configuration + +Create `agent-workflow.json` in your repository root: + +```json +{ + "version": "1", + "defaultExecutor": "reasonix", + "maxCycles": 5, + "timeoutSeconds": 1800, + "executors": { + "reasonix": { + "binary": "reasonix", + "model": "claude-opus-4" + }, + "claude": { + "binary": "claude" + }, + "agy": { + "binary": "agy", + "agent": "code-assistant" + } + } +} +``` + +## Environment Variables + +- `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`) +- `AGENT_WORKFLOW_META_STDOUT` — Emit metadata to stdout instead of stderr (set to `1`) + +## State Machine + +``` +executing → awaiting_review → awaiting_host → executing (next cycle) + ↘ awaiting_scope_resolution ↗ + +Any active state → completed | needs_human | blocked | budget_exhausted | aborted +``` + +## Scope Gates + +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`. + +## Convergence Limits + +The workflow stops with `needs_human` when: + +- The same finding persists across 2+ consecutive cycles +- Findings oscillate (disappear then reappear) + +## Development + +```bash +npm install +npm run build +npm test +npm run check +``` + +## License + +MIT — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE) for details. diff --git a/bin/agent-workflow.js b/bin/agent-workflow.js new file mode 100755 index 0000000..c667116 --- /dev/null +++ b/bin/agent-workflow.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../dist/cli.js"); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..89c4265 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1825 @@ +{ + "name": "agent-workflow", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-workflow", + "version": "0.1.0", + "license": "MIT", + "bin": { + "agent-workflow": "bin/agent-workflow.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "husky": "^9.1.7", + "tsx": "^4.23.1", + "typescript": "^5.8.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..76adb29 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "agent-workflow", + "version": "0.1.0", + "description": "Codex-hosted autonomous implementation loop with Reasonix, Claude Code, and Agy executors", + "type": "module", + "author": "liujing", + "license": "MIT", + "keywords": [ + "workflow", + "agent", + "codex", + "reasonix", + "claude", + "agy", + "autonomous", + "implementation" + ], + "bin": { + "agent-workflow": "bin/agent-workflow.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "bin/", + "dist/", + "skills/", + "README.md", + "LICENSE", + "NOTICE" + ], + "devDependencies": { + "@types/node": "^22.0.0", + "husky": "^9.1.7", + "tsx": "^4.23.1", + "typescript": "^5.8.0", + "vitest": "^4.1.10" + }, + "scripts": { + "prepare": "husky", + "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", + "build": "npm run clean && tsc", + "check": "tsc --noEmit", + "smoke": "node dist/cli.js --help >/dev/null", + "test": "vitest run", + "test:watch": "vitest", + "prepack": "npm run build" + }, + "engines": { + "node": ">=20" + } +} diff --git a/skills/agent-workflow-host/SKILL.md b/skills/agent-workflow-host/SKILL.md new file mode 100644 index 0000000..64a7db7 --- /dev/null +++ b/skills/agent-workflow-host/SKILL.md @@ -0,0 +1,152 @@ +--- +name: agent-workflow-host +description: Drive an explicitly invoked agent-workflow in which Codex freezes the latest confirmed plan, delegates implementation to Reasonix, Claude Code, or Agy, independently reviews the result, submits fix decisions to the same executor session, and repeats until accepted or safely stopped. Use only when the user explicitly invokes $agent-workflow-host to execute, resume, inspect, or abort a stateful workflow after plan discussion. +--- + +# Agent Workflow Host + +Act as the host planner, reviewer, and final acceptance authority. Let the selected executor implement and fix code. Use `agent-workflow`; do not replace its state machine with direct executor commands. + +## Invocation Contract + +Support these explicit operations: + +```text +$agent-workflow-host 执行已确认计划 +$agent-workflow-host 使用 claude 执行已确认计划 +$agent-workflow-host 继续 +$agent-workflow-host 查看 +$agent-workflow-host 清理后重试 +$agent-workflow-host 执行器重试 +$agent-workflow-host 中止 原因:需求变化 +``` + +- Treat an explicit `执行` invocation as authorization to execute the latest clearly bounded plan in the conversation. +- Do not start when the conversation contains multiple unresolved plan variants, missing scope, missing acceptance criteria, or missing verification commands. Ask only for the unresolved decision. +- If invoked without an operation, show the supported operations and do nothing. +- Default to `reasonix`. Use `claude` or `agy` only when the user explicitly selects it or the approved plan already names it. +- Never switch or fall back to another executor during a run. + +## Host Boundaries + +- 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 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. +2. Run these preflight checks: + - `command -v agent-workflow` + - `git status --porcelain` must be empty. + - Probe the selected executor with ` --version`. + - Confirm no unresolved active workflow prevents a new run. +3. Do not install tools, commit, or stash automatically. Report the exact missing prerequisite and stop. +4. Convert the approved plan to `WorkflowPlanV1` without adding new product decisions: + +```json +{ + "version": "1", + "title": "Concise title", + "planMarkdown": "Decision-complete implementation plan", + "scope": ["repo/relative/file-or-directory"], + "acceptanceCriteria": ["Observable acceptance condition"], + "verificationCommands": [["command", "arg1", "arg2"]] +} +``` + +5. Require non-empty repository-relative scope entries and at least one verification command. Use argument arrays; never encode shell pipelines, redirections, glob expansion, `&&`, or `;` as one command. +6. Store input JSON in a `mktemp -d` directory outside the repository. The workflow's durable state is the audit record; remove temporary input files after a terminal result. +7. Start the run and record the emitted run ID: + +```bash +agent-workflow start --plan --executor +``` + +Honor an explicit `max-cycles` argument. Otherwise use project configuration and workflow defaults. + +## Drive The State Machine + +Continue until a terminal status. Do not stop after merely proposing a decision. + +### `awaiting_review` + +1. Run `agent-workflow review --run `. +2. Read the emitted `hostReviewFile`. Inspect its complete `diffFile`, full `executorLogFile`, plan, acceptance criteria, repository code, changed-file scope, and HEAD evidence yourself. +3. Review for correctness, regressions, security, and missing tests. Assign stable IDs such as `codex--1` to concrete issues. +4. Independently run every `verificationCommands` entry from the repository root and retain concise evidence. + +### `awaiting_scope_resolution` + +1. Confirm the Codex review bundle was not prepared and inspect every structured `scopeViolations` entry. +2. Decide whether each listed path was created by the executor and can be safely removed or restored. Never broaden scope, add ignore rules, or delete automatically. +3. To use `outcome: "fix"`, accept every current `scope-*` finding ID exactly once. The CLI resumes the same executor session with a prompt limited to those exact paths. +4. Use `needs_human` when any path may contain intentional work or ownership is unclear. Never use `accept` in this state. +5. After remediation, run `agent-workflow review` again. Codex review may proceed only after HEAD and scope gates pass. + +### `awaiting_host` + +Classify every issue found by Codex: + +- `accept`: real, actionable, in scope, and required for the approved acceptance criteria. +- `reject`: unsupported or incorrect; include a concrete rationale. +- `followup`: valid but outside the frozen task; record it without sending it for implementation. + +Create `HostDecisionV1` in the temporary directory: + +```json +{ + "version": "1", + "outcome": "fix", + "findingDecisions": [ + { + "findingId": "codex-2-1", + "disposition": "accept", + "summary": "Concrete problem and required behavior", + "path": "src/example.ts", + "rationale": "Evidence-based reason" + } + ], + "verificationEvidence": ["command: observed result"], + "decidedAt": "ISO-8601 timestamp" +} +``` + +- Use `outcome: "fix"` when at least one accepted finding must be fixed. Every accepted Codex finding requires a non-empty `summary`; include `path` when known. Submit it with `agent-workflow decide --run --input `. The CLI must resume the same executor session. +- Use `outcome: "accept"` only when Codex has completed its own review, has no blocking issue, every verification command passes, HEAD is unchanged, and every changed file is in scope. +- Use `outcome: "needs_human"` when intent, safety, architecture, or evidence cannot be resolved without the user. +- Never send rejected findings or follow-ups to the executor as required fixes. + +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 + +- For `查看 `, run `agent-workflow status --run --json`, summarize it, and do not advance the workflow. +- For `继续 `, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory. +- For `清理后重试 `, run `agent-workflow retry-review --run ` after the approved scope artifacts have been removed. It must refuse when HEAD drift or out-of-scope paths remain. +- For `执行器重试 `, run `agent-workflow retry-execute --run ` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when HEAD has drifted, scope violations exist, or the cycle already produced review artifacts. +- Treat `completed`, `needs_human`, `blocked`, `budget_exhausted`, and `aborted` as terminal. Report them instead of attempting another cycle. +- For an explicit abort, run `agent-workflow abort --run --reason ` and report the resulting status. + +## Failure Handling + +- Missing session handle, executor failure, or HEAD drift must stop as `blocked`; do not work around the gate. +- 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 interrupted, preserve the run ID. A later explicit `继续 ` invocation resumes from durable state. +- If the user changes requirements, abort the active run and return to plan discussion. Do not mutate the frozen plan in place. + +## Final Report + +Report: + +- Run ID, executor, terminal status, and cycle count. +- Files changed and how they satisfy the approved plan. +- Verification commands and observed results. +- Accepted fixes, rejected findings with rationale, and recorded follow-ups. +- Stop reason and remaining risk, if any. + +Claim completion only for terminal `completed` after the clean acceptance gate passes. diff --git a/skills/agent-workflow-host/agents/openai.yaml b/skills/agent-workflow-host/agents/openai.yaml new file mode 100644 index 0000000..09b7319 --- /dev/null +++ b/skills/agent-workflow-host/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Agent Workflow Host" + short_description: "让 Codex 驱动已确认计划的执行、独立审查与持续修复闭环" + default_prompt: "Use $agent-workflow-host to execute the latest confirmed plan with Reasonix and review until accepted." + +policy: + allow_implicit_invocation: false diff --git a/src/child-process.test.ts b/src/child-process.test.ts new file mode 100644 index 0000000..0a1dca6 --- /dev/null +++ b/src/child-process.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { Writable } from "node:stream"; +import { test } from "vitest"; +import { spawnBufferedChild, spawnStreamingChild } from "./child-process.js"; + +function collectSink(): { stream: Writable; chunks: string[] } { + const chunks: string[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + chunks.push(String(chunk)); + cb(); + }, + }); + return { stream, chunks }; +} + +/** Resolve once the collected chunks match `pattern`, with a generous timeout + * so we never hang forever if the child never emits. */ +function waitForOutput(chunks: string[], pattern: RegExp, timeoutMs = 2000): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + if (pattern.test(chunks.join(""))) return resolve(); + if (Date.now() - start > timeoutMs) return reject(new Error(`timed out waiting for ${pattern}`)); + setTimeout(tick, 5); + }; + tick(); + }); +} + +test("spawnBufferedChild captures stdout", () => { + const r = spawnBufferedChild(process.execPath, ["-e", "console.log('buf')"], {}); + assert.equal(r.status, 0); + assert.match(r.stdout, /buf/); +}); + +test("spawnStreamingChild reports signal when child is killed", async () => { + const { stream } = collectSink(); + const r = await spawnStreamingChild( + process.execPath, + ["-e", "process.kill(process.pid, 'SIGTERM')"], + { stdoutSink: stream, stderrSink: stream }, + ); + assert.equal(r.status, null); + assert.equal(r.signal, "SIGTERM"); +}); + +test("spawnStreamingChild forwards chunks and collects full stdout", async () => { + const script = [ + "console.log('line1');", + "setTimeout(() => console.log('line2'), 30);", + ].join(""); + const { stream, chunks } = collectSink(); + const r = await spawnStreamingChild(process.execPath, ["-e", script], { + stdoutSink: stream, + stderrSink: stream, + }); + assert.equal(r.status, 0); + assert.match(r.stdout, /line1/); + assert.match(r.stdout, /line2/); + const forwarded = chunks.join(""); + assert.match(forwarded, /line1/); + assert.match(forwarded, /line2/); +}); + +test("spawnStreamingChild keeps captured stdout bounded while forwarding all output", async () => { + const script = "process.stdout.write('abcde'); process.stdout.write('fghij');"; + const { stream, chunks } = collectSink(); + const r = await spawnStreamingChild(process.execPath, ["-e", script], { + stdoutSink: stream, + stderrSink: stream, + maxCaptureChars: 6, + }); + + assert.equal(r.status, 0); + assert.equal(r.stdout, "efghij"); + assert.equal(chunks.join(""), "abcdefghij"); +}); + +test("spawnStreamingChild terminates an opted-in process group when aborted", async () => { + const controller = new AbortController(); + const { stream } = collectSink(); + const result = spawnStreamingChild(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdoutSink: stream, + stderrSink: stream, + signal: controller.signal, + processGroup: true, + }); + setTimeout(() => controller.abort(), 30); + const child = await result; + assert.equal(child.signal, "SIGTERM"); +}); + +test("spawnStreamingChild escalates ignored SIGTERM to SIGKILL after the abort grace period", async () => { + const controller = new AbortController(); + const { stream, chunks } = collectSink(); + // Register the ignore handler first, then stay alive until SIGKILL. + const script = [ + "process.on('SIGTERM', () => { process.stdout.write('ignored\\n'); });", + "process.stdout.write('ready\\n');", + "setInterval(() => {}, 1000);", + ].join(""); + const result = spawnStreamingChild(process.execPath, ["-e", script], { + stdoutSink: stream, + stderrSink: stream, + signal: controller.signal, + processGroup: false, + abortKillGraceMs: 50, + }); + // Wait until the child has actually registered its SIGTERM handler and + // signalled readiness, rather than a fixed delay — under parallel load the + // child can take longer to start, and aborting before the handler is + // installed would let SIGTERM kill it (observed as SIGTERM instead of SIGKILL). + await waitForOutput(chunks, /ready/); + controller.abort(); + const child = await result; + assert.equal(child.signal, "SIGKILL"); + assert.match(chunks.join("") + child.stdout, /ready/); +}); diff --git a/src/child-process.ts b/src/child-process.ts new file mode 100644 index 0000000..d68e1df --- /dev/null +++ b/src/child-process.ts @@ -0,0 +1,151 @@ +import { spawn, spawnSync, type SpawnOptions } from "node:child_process"; + +export type ChildRunResult = { + status: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + error?: Error; +}; + +export const DEFAULT_STREAM_CAPTURE_LIMIT = 50 * 1024 * 1024; + +type SpawnCommon = { + cwd?: string; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + /** Start a separate process group so abort can terminate descendants too. */ + processGroup?: boolean; +}; + +function appendBoundedTail(current: string, chunk: string, limit: number): string { + if (limit <= 0) return ""; + const next = current + chunk; + return next.length > limit ? next.slice(next.length - limit) : next; +} + +export function spawnBufferedChild( + command: string, + argv: string[], + options: SpawnCommon, +): ChildRunResult { + const child = spawnSync(command, argv, { + cwd: options.cwd, + env: options.env, + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + }); + return { + status: child.status, + signal: child.signal, + stdout: child.stdout || "", + stderr: child.stderr || "", + error: child.error, + }; +} + +export const DEFAULT_ABORT_KILL_GRACE_MS = 2_000; + +export async function spawnStreamingChild( + command: string, + argv: string[], + options: SpawnCommon & { + stdoutSink?: NodeJS.WritableStream; + stderrSink?: NodeJS.WritableStream; + maxCaptureChars?: number; + /** After SIGTERM, escalate to SIGKILL if the process is still alive. */ + abortKillGraceMs?: number; + }, +): Promise { + const stdoutSink = options.stdoutSink ?? process.stdout; + const stderrSink = options.stderrSink ?? process.stderr; + const maxCaptureChars = options.maxCaptureChars ?? DEFAULT_STREAM_CAPTURE_LIMIT; + const abortKillGraceMs = options.abortKillGraceMs ?? DEFAULT_ABORT_KILL_GRACE_MS; + + return new Promise((resolve) => { + const spawnOpts: SpawnOptions = { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + // Preserve terminal signal delivery for ordinary streaming children. + // Workflow callers opt into a group so their abort signal reaches executor descendants. + detached: options.processGroup === true && process.platform !== "win32", + }; + const child = spawn(command, argv, spawnOpts); + let stdout = ""; + let stderr = ""; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + + const finish = (result: ChildRunResult) => { + if (settled) return; + settled = true; + if (killTimer) clearTimeout(killTimer); + options.signal?.removeEventListener("abort", abort); + resolve(result); + }; + + const sendSignal = (signal: NodeJS.Signals) => { + if (child.pid === undefined) return; + try { + if (options.processGroup && process.platform === "win32") { + spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + } else if (options.processGroup) { + process.kill(-child.pid, signal); + } else { + child.kill(signal); + } + } catch { + try { + child.kill(signal); + } catch { + // Process already gone. + } + } + }; + + const abort = () => { + if (child.pid === undefined || settled) return; + sendSignal("SIGTERM"); + if (process.platform === "win32") return; + killTimer = setTimeout(() => { + if (!settled) sendSignal("SIGKILL"); + }, Math.max(0, abortKillGraceMs)); + killTimer.unref?.(); + }; + + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (chunk: string) => { + stdout = appendBoundedTail(stdout, chunk, maxCaptureChars); + stdoutSink.write(chunk); + }); + child.stderr?.on("data", (chunk: string) => { + stderr = appendBoundedTail(stderr, chunk, maxCaptureChars); + stderrSink.write(chunk); + }); + + child.on("error", (error) => { + finish({ + status: null, + signal: null, + stdout, + stderr, + error, + }); + }); + + child.on("close", (status, signal) => { + finish({ + status, + signal, + stdout, + stderr, + }); + }); + }); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..da552ee --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * agent-workflow CLI entry point. + * + * Supports direct subcommands: start, review, retry-review, retry-execute, decide, status, abort. + */ + +import { + parseWorkflowArgs, + workflowUsage, + type WorkflowArgs, +} from "./workflow-args.js"; +import { + startWorkflow, + reviewWorkflow, + decideWorkflow, + statusWorkflow, + abortWorkflow, + retryReviewWorkflow, + retryExecuteWorkflow, + WorkflowEngineError, +} from "./workflow-engine.js"; +import { WorkflowConfigError } from "./workflow-config.js"; +import { WorkflowLockError } from "./workflow-state.js"; + +async function main() { + let args: WorkflowArgs; + try { + args = parseWorkflowArgs(process.argv.slice(2)); + } catch (err) { + if (err instanceof Error && err.name === "WorkflowArgsError") { + process.stderr.write(`Error: ${err.message}\n\n`); + workflowUsage(1); + } + throw err; + } + + try { + switch (args.sub) { + case "start": + await startWorkflow({ + planInput: args.planInput, + executor: args.executor, + maxCycles: args.maxCycles, + }); + break; + + case "review": + await reviewWorkflow({ runId: args.runId }); + break; + + case "retry-review": + retryReviewWorkflow({ runId: args.runId }); + break; + + case "retry-execute": + await retryExecuteWorkflow({ runId: args.runId }); + break; + + case "decide": + await decideWorkflow({ + runId: args.runId, + decisionInput: args.decisionInput, + }); + break; + + case "status": + statusWorkflow({ runId: args.runId, json: args.json }); + break; + + case "abort": + abortWorkflow({ runId: args.runId, reason: args.reason }); + break; + + default: { + const _exhaustive: never = args; + throw new Error(`Unknown subcommand: ${(_exhaustive as any).sub}`); + } + } + } catch (err) { + if (err instanceof WorkflowEngineError) { + process.stderr.write(`Error: ${err.message}\n`); + process.exit(err.exitCode); + } + if (err instanceof WorkflowConfigError) { + process.stderr.write(`Configuration error: ${err.message}\n`); + process.exit(4); + } + if (err instanceof WorkflowLockError) { + process.stderr.write(`Lock error: ${err.message}\n`); + process.exit(4); + } + throw err; + } +} + +main().catch((err) => { + process.stderr.write(`Unexpected error: ${err.message}\n`); + if (err.stack) { + process.stderr.write(err.stack + "\n"); + } + process.exit(1); +}); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..42d5ee3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,152 @@ +/** + * agent-workflow — Codex-hosted autonomous implementation loop. + * + * Public API exports for programmatic use. + */ + +export { + startWorkflow, + reviewWorkflow, + decideWorkflow, + statusWorkflow, + abortWorkflow, + retryReviewWorkflow, + retryExecuteWorkflow, + WorkflowEngineError, + emitWorkflowMeta, +} from "./workflow-engine.js"; + +export type { + StartWorkflowOpts, + ReviewWorkflowOpts, + DecideWorkflowOpts, + StatusWorkflowOpts, + AbortWorkflowOpts, + RetryReviewWorkflowOpts, + RetryExecuteWorkflowOpts, +} from "./workflow-engine.js"; + +export { + WorkflowConfigError, + loadProjectWorkflowConfig, + resolveWorkflowConfig, + validateWorkflowPlan, + validateHostDecision, +} from "./workflow-config.js"; + +export type { + WorkflowCliOverrides, +} from "./workflow-config.js"; + +export { + workflowsRoot, + repoHash, + runDir, + lockFilePath, + stateFilePath, + eventsFilePath, + cycleDiffFile, + cycleExecLogFile, + cycleHostReviewFile, + cycleDecisionFile, + generateRunId, + writeState, + readState, + appendEvent, + WorkflowLockError, + acquireLock, + releaseLock, + readLock, + GitError, + gitRepoRoot, + gitHead, + gitIsClean, + gitDiffFromHead, + checkFilesInScope, + nowIso, + initWorkflowState, + findRunDir, +} from "./workflow-state.js"; + +export { + checkConvergence, + decisionSignatures, +} from "./workflow-convergence.js"; + +export type { + FindingSignature, + ConvergenceCheckResult, +} from "./workflow-convergence.js"; + +export { + createExecutorAdapter, + probeExecutor, +} from "./workflow-executor.js"; + +export type { + ExecutorAdapter, + ExecutorResult, + ExecutorStartOpts, + ExecutorResumeOpts, +} from "./workflow-types.js"; + +export { + parseWorkflowArgs, + workflowUsage, +} from "./workflow-args.js"; + +export type { + WorkflowSubcommand, + WorkflowArgs, + WorkflowStartArgs, + WorkflowReviewArgs, + WorkflowRetryReviewArgs, + WorkflowRetryExecuteArgs, + WorkflowDecideArgs, + WorkflowStatusArgs, + WorkflowAbortArgs, +} from "./workflow-args.js"; + +export type { + WorkflowPlanV1, + WorkflowState, + WorkflowStatus, + WorkflowActiveStatus, + WorkflowTerminalStatus, + WorkflowStatusOutput, + HostDecisionV1, + HostDecisionOutcome, + FindingDecision, + HostReviewBundleV1, + WorkflowProjectConfig, + ResolvedWorkflowConfig, + ExecutorConfig, + ExecutorKind, + ExecutorSessionHandle, + ReasonixSessionHandle, + ClaudeSessionHandle, + AgySessionHandle, + CycleRecord, + ScopeViolation, + WorkflowEvent, + WorkflowEventKind, +} from "./workflow-types.js"; + +export { + EXECUTOR_KINDS, + WORKFLOW_STATUSES, + WORKFLOW_ACTIVE_STATUSES, + WORKFLOW_TERMINAL_STATUSES, + HOST_DECISION_OUTCOMES, +} from "./workflow-types.js"; + +export { + spawnBufferedChild, + spawnStreamingChild, + DEFAULT_STREAM_CAPTURE_LIMIT, + DEFAULT_ABORT_KILL_GRACE_MS, +} from "./child-process.js"; + +export type { + ChildRunResult, +} from "./child-process.js"; diff --git a/src/workflow-args.ts b/src/workflow-args.ts new file mode 100644 index 0000000..eba0fee --- /dev/null +++ b/src/workflow-args.ts @@ -0,0 +1,258 @@ +/** + * Argument parsing for agent-workflow subcommands. + */ + +export type WorkflowSubcommand = "start" | "review" | "retry-review" | "retry-execute" | "decide" | "status" | "abort"; + +export interface WorkflowStartArgs { + sub: "start"; + executor?: string; + planInput: string; // file path or "-" + maxCycles?: number; +} + +export interface WorkflowReviewArgs { + sub: "review"; + runId: string; +} + +export interface WorkflowRetryReviewArgs { + sub: "retry-review"; + runId: string; +} + +export interface WorkflowRetryExecuteArgs { + sub: "retry-execute"; + runId: string; +} + +export interface WorkflowDecideArgs { + sub: "decide"; + runId: string; + decisionInput: string; // file path or "-" +} + +export interface WorkflowStatusArgs { + sub: "status"; + runId: string; + json: boolean; +} + +export interface WorkflowAbortArgs { + sub: "abort"; + runId: string; + reason: string; +} + +export type WorkflowArgs = + | WorkflowStartArgs + | WorkflowReviewArgs + | WorkflowRetryReviewArgs + | WorkflowRetryExecuteArgs + | WorkflowDecideArgs + | WorkflowStatusArgs + | WorkflowAbortArgs; + +export class WorkflowArgsError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowArgsError"; + } +} + +function requireValue(flag: string, argv: string[]): string { + if (argv.length === 0 || argv[0].startsWith("--")) { + throw new WorkflowArgsError(`${flag} requires a value`); + } + return argv.shift()!; +} + +function parsePositiveInt(flag: string, value: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n < 1) { + throw new WorkflowArgsError(`${flag} must be a positive integer`); + } + return n; +} + +const WORKFLOW_USAGE = ` +Usage: + agent-workflow start --executor --plan [--max-cycles ] + agent-workflow review --run + agent-workflow retry-review --run + agent-workflow retry-execute --run + agent-workflow decide --run --input + agent-workflow status --run [--json] + agent-workflow abort --run --reason + +Outputs AGENT_WORKFLOW_META_JSON: to stderr (set AGENT_WORKFLOW_META_STDOUT=1 for stdout). + +Examples: + agent-workflow start --plan plan.json --executor reasonix + agent-workflow start --plan - --executor claude --max-cycles 3 # read plan from stdin + agent-workflow review --run 2026-01-01T00-00-00_01_abc123 + agent-workflow retry-review --run 2026-01-01T00-00-00_01_abc123 + agent-workflow retry-execute --run 2026-01-01T00-00-00_01_abc123 + agent-workflow decide --run --input decision.json + agent-workflow decide --run --input - # read decision from stdin + agent-workflow status --run + agent-workflow status --run --json + agent-workflow abort --run --reason "changed requirements" +`.trimStart(); + +export function workflowUsage(exitCode = 0): never { + (exitCode === 0 ? process.stdout : process.stderr).write(WORKFLOW_USAGE); + process.exit(exitCode); +} + +export function parseWorkflowArgs(argv: string[]): WorkflowArgs { + if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") { + workflowUsage(0); + } + + const sub = argv.shift()!; + const rest = [...argv]; + + switch (sub) { + case "start": { + let executor: string | undefined; + let planInput: string | undefined; + let maxCycles: number | undefined; + + while (rest.length > 0) { + const arg = rest.shift()!; + switch (arg) { + case "--executor": + executor = requireValue(arg, rest); + break; + case "--plan": + planInput = requireValue(arg, rest); + break; + case "--max-cycles": + maxCycles = parsePositiveInt(arg, requireValue(arg, rest)); + break; + case "-h": + case "--help": + workflowUsage(0); + break; + default: + throw new WorkflowArgsError(`Unknown option: ${arg}`); + } + } + + if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start"); + + return { sub: "start", executor, planInput, maxCycles }; + } + + case "review": { + let runId: string | undefined; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = 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 review"); + return { sub: "review", runId }; + } + + case "retry-review": { + let runId: string | undefined; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = 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 retry-review"); + return { sub: "retry-review", runId }; + } + + case "retry-execute": { + let runId: string | undefined; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = 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 retry-execute"); + return { sub: "retry-execute", runId }; + } + + case "decide": { + let runId: string | undefined; + let decisionInput: string | undefined; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = requireValue(arg, rest); + } else if (arg === "--input") { + decisionInput = 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 decide"); + if (!decisionInput) throw new WorkflowArgsError("--input is required for workflow decide"); + return { sub: "decide", runId, decisionInput }; + } + + case "status": { + let runId: string | undefined; + let json = false; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = requireValue(arg, rest); + } else if (arg === "--json") { + json = true; + } 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 status"); + return { sub: "status", runId, json }; + } + + case "abort": { + let runId: string | undefined; + let reason: string | undefined; + while (rest.length > 0) { + const arg = rest.shift()!; + if (arg === "--run") { + runId = requireValue(arg, rest); + } else if (arg === "--reason") { + reason = 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 abort"); + if (!reason) throw new WorkflowArgsError("--reason is required for workflow abort"); + return { sub: "abort", runId, reason }; + } + + default: + throw new WorkflowArgsError(`Unknown workflow subcommand: ${sub}\n\n${WORKFLOW_USAGE}`); + } +} diff --git a/src/workflow-config.ts b/src/workflow-config.ts new file mode 100644 index 0000000..3cdc282 --- /dev/null +++ b/src/workflow-config.ts @@ -0,0 +1,172 @@ +/** + * Workflow configuration resolution. + * + * Priority: CLI flags > agent-workflow.json (project root) > built-in defaults. + * Keys are never secrets; no API keys stored in config. + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { + ExecutorConfig, + ExecutorKind, + ResolvedWorkflowConfig, + WorkflowProjectConfig, +} from "./workflow-types.js"; +import { EXECUTOR_KINDS } from "./workflow-types.js"; + +const WORKFLOW_CONFIG_FILE = "agent-workflow.json"; + +const DEFAULTS = { + executor: "reasonix" as ExecutorKind, + maxCycles: 5, + timeoutSeconds: 1800, +}; + +export class WorkflowConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "WorkflowConfigError"; + } +} + +/** Load and parse agent-workflow.json from repoRoot. */ +export function loadProjectWorkflowConfig(repoRoot: string): WorkflowProjectConfig | undefined { + const filePath = path.join(repoRoot, WORKFLOW_CONFIG_FILE); + if (!fs.existsSync(filePath)) return undefined; + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (err) { + throw new WorkflowConfigError(`Failed to parse ${WORKFLOW_CONFIG_FILE}: ${(err as Error).message}`); + } + return validateProjectConfig(raw, filePath); +} + +function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig { + if (!raw || typeof raw !== "object") { + throw new WorkflowConfigError(`${filePath}: must be a JSON object`); + } + const obj = raw as Record; + if (obj.version !== "1") { + throw new WorkflowConfigError(`${filePath}: version must be "1"`); + } + 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.maxCycles !== undefined && (typeof obj.maxCycles !== "number" || !Number.isInteger(obj.maxCycles) || obj.maxCycles < 1)) { + throw new WorkflowConfigError(`${filePath}: maxCycles must be a positive integer`); + } + if (obj.timeoutSeconds !== undefined && (typeof obj.timeoutSeconds !== "number" || !Number.isInteger(obj.timeoutSeconds) || obj.timeoutSeconds < 1)) { + throw new WorkflowConfigError(`${filePath}: timeoutSeconds must be a positive integer`); + } + // Prohibit secrets in config + const forbidden = ["apikey", "api_key", "token", "secret", "password", "credential"]; + const lower = JSON.stringify(raw).toLowerCase(); + for (const key of forbidden) { + if (lower.includes(`"${key}"`)) { + throw new WorkflowConfigError(`${filePath}: config must not contain secrets (found "${key}")`); + } + } + return raw as WorkflowProjectConfig; +} + +export interface WorkflowCliOverrides { + executor?: ExecutorKind; + maxCycles?: number; +} + +/** Merge CLI > project config > defaults into a single resolved config. */ +export function resolveWorkflowConfig( + repoRoot: string, + cli: WorkflowCliOverrides = {}, +): ResolvedWorkflowConfig { + const project = loadProjectWorkflowConfig(repoRoot); + + const executor: ExecutorKind = cli.executor ?? (project?.defaultExecutor) ?? DEFAULTS.executor; + if (!EXECUTOR_KINDS.includes(executor)) { + throw new WorkflowConfigError(`Unknown executor: ${executor}. Must be one of: ${EXECUTOR_KINDS.join(", ")}`); + } + + 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}`); + } + + const timeoutSeconds = project?.timeoutSeconds ?? DEFAULTS.timeoutSeconds; + + const executorConfig: ExecutorConfig = { + ...((project?.executors as Record | undefined)?.[executor] ?? {}), + }; + + return { + executor, + maxCycles, + timeoutSeconds, + executorConfig, + }; +} + +/** Validate a WorkflowPlanV1 object and throw if invalid. */ +export function validateWorkflowPlan(raw: unknown): asserts raw is import("./workflow-types.js").WorkflowPlanV1 { + if (!raw || typeof raw !== "object") throw new WorkflowConfigError("Plan must be a JSON object"); + const obj = raw as Record; + if (obj.version !== "1") throw new WorkflowConfigError('Plan version must be "1"'); + if (typeof obj.title !== "string" || !obj.title.trim()) throw new WorkflowConfigError("Plan must have a non-empty title"); + if (typeof obj.planMarkdown !== "string" || !obj.planMarkdown.trim()) throw new WorkflowConfigError("Plan must have a non-empty planMarkdown"); + if (!Array.isArray(obj.scope) || obj.scope.length === 0) throw new WorkflowConfigError("Plan scope must be a non-empty array"); + for (const s of obj.scope) { + if (typeof s !== "string") throw new WorkflowConfigError("Plan scope entries must be strings"); + } + if (!Array.isArray(obj.acceptanceCriteria) || obj.acceptanceCriteria.length === 0) { + throw new WorkflowConfigError("Plan acceptanceCriteria must be a non-empty array"); + } + if (!Array.isArray(obj.verificationCommands) || obj.verificationCommands.length === 0) { + throw new WorkflowConfigError("Plan verificationCommands must be a non-empty array"); + } + for (const cmd of obj.verificationCommands as unknown[]) { + if (!Array.isArray(cmd) || cmd.some((t) => typeof t !== "string")) { + throw new WorkflowConfigError("Plan verificationCommands entries must be string arrays"); + } + if (cmd.length === 0) { + throw new WorkflowConfigError("Plan verificationCommands entries must not be empty arrays"); + } + if (typeof cmd[0] !== "string" || !cmd[0].trim()) { + throw new WorkflowConfigError("Plan verificationCommands must specify a non-empty executable command"); + } + } +} + +/** Validate a HostDecisionV1 and throw if invalid. */ +export function validateHostDecision(raw: unknown): asserts raw is import("./workflow-types.js").HostDecisionV1 { + if (!raw || typeof raw !== "object") throw new WorkflowConfigError("Decision must be a JSON object"); + const obj = raw as Record; + if (obj.version !== "1") throw new WorkflowConfigError('Decision version must be "1"'); + const validOutcomes = ["fix", "accept", "needs_human"] as const; + if (!validOutcomes.includes(obj.outcome as never)) { + throw new WorkflowConfigError(`Decision outcome must be one of: ${validOutcomes.join(", ")}`); + } + if (!Array.isArray(obj.findingDecisions)) { + throw new WorkflowConfigError("Decision findingDecisions must be an array"); + } + for (const finding of obj.findingDecisions) { + if (!finding || typeof finding !== "object") { + throw new WorkflowConfigError("Decision findingDecisions entries must be objects"); + } + const item = finding as Record; + if (typeof item.findingId !== "string" || !item.findingId.trim()) { + throw new WorkflowConfigError("Decision findingDecisions entries require a non-empty findingId"); + } + if (!["accept", "reject", "followup"].includes(String(item.disposition))) { + throw new WorkflowConfigError("Decision finding disposition must be accept, reject, or followup"); + } + for (const key of ["summary", "path", "rationale"] as const) { + if (item[key] !== undefined && typeof item[key] !== "string") { + throw new WorkflowConfigError(`Decision finding ${key} must be a string`); + } + } + } + if (typeof obj.decidedAt !== "string") { + throw new WorkflowConfigError("Decision decidedAt must be a string"); + } +} diff --git a/src/workflow-convergence.ts b/src/workflow-convergence.ts new file mode 100644 index 0000000..9db90f6 --- /dev/null +++ b/src/workflow-convergence.ts @@ -0,0 +1,80 @@ +/** + * Convergence detector for the workflow loop. + * + * Detects two stop conditions: + * 1. The same finding (same path + normalized summary) persists for 2+ consecutive cycles. + * 2. Findings oscillate between cycles (a finding disappears then reappears). + * + * Both conditions produce needs_human. + */ + +import type { FindingDecision } from "./workflow-types.js"; + +export interface FindingSignature { + path?: string; + normalizedSummary: string; +} + +/** Normalize a finding summary for stable comparison. */ +function normalizeSummary(summary: string): string { + return summary + .toLowerCase() + .replace(/[^\w\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Build convergence signatures from the issues Codex accepted for fixing. */ +export function decisionSignatures(findings: FindingDecision[]): FindingSignature[] { + return findings + .filter((finding) => finding.disposition === "accept") + .map((finding) => ({ + ...(finding.path ? { path: finding.path } : {}), + normalizedSummary: normalizeSummary(finding.summary || finding.rationale || finding.findingId), + })); +} + +function sigKey(sig: FindingSignature): string { + return `${sig.path ?? ""}::${sig.normalizedSummary}`; +} + +export interface ConvergenceCheckResult { + converging: boolean; + /** Set when the same finding persists for 2 consecutive cycles. */ + persistentFindings?: string[]; + /** Set when findings oscillate (disappeared then reappeared). */ + oscillatingFindings?: string[]; +} + +/** + * Check the last few rounds' signatures for convergence. + * + * @param history Array of per-cycle finding signatures, oldest first. + */ +export function checkConvergence(history: FindingSignature[][]): ConvergenceCheckResult { + if (history.length < 2) return { converging: true }; + + const last = history[history.length - 1]!; + const prev = history[history.length - 2]!; + + const lastKeys = new Set(last.map(sigKey)); + const prevKeys = new Set(prev.map(sigKey)); + + // Persistent: same key in both consecutive cycles + const persistent = [...lastKeys].filter((k) => prevKeys.has(k)); + if (persistent.length > 0) { + return { converging: false, persistentFindings: persistent }; + } + + // Oscillating: check 3+ cycles (a key was present, absent, then present again) + if (history.length >= 3) { + const earlier = history[history.length - 3]!; + const earlierKeys = new Set(earlier.map(sigKey)); + const oscillating = [...lastKeys].filter((k) => earlierKeys.has(k) && !prevKeys.has(k)); + if (oscillating.length > 0) { + return { converging: false, oscillatingFindings: oscillating }; + } + } + + return { converging: true }; +} diff --git a/src/workflow-engine.ts b/src/workflow-engine.ts new file mode 100644 index 0000000..c9fd5ae --- /dev/null +++ b/src/workflow-engine.ts @@ -0,0 +1,1178 @@ +/** + * Workflow engine — implements the workflow CLI subcommands: + * + * start — validates plan, acquires lock, starts first execution cycle + * review — generates a review bundle for the Codex host + * retry-review — resumes a cleaned scope-gate review + * decide — receives Codex HostDecisionV1, drives state transitions + * status — prints current run state + * abort — terminates a run with a reason + * + * State machine: + * executing → awaiting_review → awaiting_host → executing (next cycle) + * ↘ awaiting_scope_resolution ↗ + * Any active state → completed | needs_human | blocked | budget_exhausted | aborted + * + * Outputs AGENT_WORKFLOW_META_JSON: to stderr (or stdout when AGENT_WORKFLOW_META_STDOUT=1). + */ + +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 { checkConvergence, decisionSignatures } from "./workflow-convergence.js"; +import type { FindingSignature } from "./workflow-convergence.js"; +import { createExecutorAdapter, probeExecutor } from "./workflow-executor.js"; +import { + WorkflowLockError, + acquireLock, + appendEvent, + checkFilesInScope, + cycleDiffFile, + cycleDecisionFile, + cycleExecLogFile, + cycleHostReviewFile, + findRunDir, + generateRunId, + gitDiffFromHead, + gitHead, + gitIsClean, + gitRepoRoot, + initWorkflowState, + nowIso, + readState, + releaseLock, + runDir as makeRunDir, + writeState, +} from "./workflow-state.js"; +import type { + CycleRecord, + ExecutorResult, + HostDecisionV1, + HostReviewBundleV1, + ScopeViolation, + WorkflowPlanV1, + WorkflowState, + WorkflowStatusOutput, + WorkflowTerminalStatus, +} from "./workflow-types.js"; + +// --------------------------------------------------------------------------- +// Managed Abort Controllers for SIGTERM handling +// --------------------------------------------------------------------------- + +const activeAbortControllers = new Set(); + +process.on("SIGTERM", () => { + for (const ac of activeAbortControllers) { + ac.abort(); + } + setTimeout(() => { + process.exit(143); + }, 500).unref(); +}); + +function createManagedController(timeoutSeconds?: number): { ac: AbortController; cleanup: () => void } { + const ac = new AbortController(); + activeAbortControllers.add(ac); + let timer: NodeJS.Timeout | undefined; + if (timeoutSeconds) { + timer = setTimeout(() => { + ac.abort(); + }, timeoutSeconds * 1000); + timer.unref(); + } + return { + ac, + cleanup: () => { + if (timer) clearTimeout(timer); + activeAbortControllers.delete(ac); + }, + }; +} + +function writeStateSafety(dir: string, state: WorkflowState): void { + const diskState = readState(dir); + if (diskState && ["aborted", "completed", "needs_human", "blocked", "budget_exhausted"].includes(diskState.status)) { + throw new WorkflowEngineError(`Workflow was terminated externally (status: ${diskState.status}).`); + } + writeState(dir, state); +} + +export function emitWorkflowMeta(meta: Record): void { + const line = `AGENT_WORKFLOW_META_JSON: ${JSON.stringify(meta)}\n`; + const dest = process.env.AGENT_WORKFLOW_META_STDOUT?.toLowerCase(); + if (dest === "1" || dest === "true" || dest === "stdout") { + process.stdout.write(line); + } else { + process.stderr.write(line); + } +} + +// --------------------------------------------------------------------------- +// Helper: load state or fail +// --------------------------------------------------------------------------- + +function requireState(runId: string, repoRoot?: string): { state: WorkflowState; dir: string } { + const dir = findRunDir(runId, repoRoot); + if (!dir) throw new WorkflowEngineError(`Run '${runId}' not found.`); + const state = readState(dir); + if (!state) throw new WorkflowEngineError(`State for run '${runId}' is corrupt or missing.`); + return { state, dir }; +} + +export class WorkflowEngineError extends Error { + constructor( + message: string, + readonly exitCode: number = 4, + ) { + super(message); + this.name = "WorkflowEngineError"; + } +} + +function makeScopeViolations(paths: string[]): ScopeViolation[] { + return [...new Set(paths)].sort().map((filePath, index) => ({ + id: `scope-${index + 1}`, + path: filePath, + kind: "out_of_scope", + })); +} + +function scopeViolationPaths(violations: ScopeViolation[] | undefined): string[] { + return (violations ?? []).map((violation) => violation.path).sort(); +} + +function sameScopeViolations(a: ScopeViolation[] | undefined, b: ScopeViolation[]): boolean { + const left = scopeViolationPaths(a); + const right = scopeViolationPaths(b); + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +// --------------------------------------------------------------------------- +// Terminal transition helper +// --------------------------------------------------------------------------- + +function transitionToTerminal( + state: WorkflowState, + dir: string, + terminal: WorkflowTerminalStatus, + description: string, + data?: Record, +): void { + const diskState = readState(dir); + if (diskState && ["aborted", "completed", "needs_human", "blocked", "budget_exhausted"].includes(diskState.status)) { + return; + } + state.status = terminal; + state.stopReason = terminal; + state.stopDescription = description; + state.enginePid = undefined; + state.updatedAt = nowIso(); + writeState(dir, state); + appendEvent(dir, { + kind: terminal === "aborted" ? "workflow_aborted" : "workflow_stopped", + runId: state.runId, + timestamp: state.updatedAt, + data: { reason: terminal, description, ...data }, + }); + releaseLock(state.repoRoot, state.runId); +} + +function persistExecutorAttempt( + dir: string, + cycleRecord: CycleRecord, + execResult: ExecutorResult, +): void { + const priorLogs = cycleRecord.executorAttemptLogs + ?? (cycleRecord.executorLogFile ? [cycleRecord.executorLogFile] : []); + const attemptIndex = priorLogs.length; + const execLog = attemptIndex === 0 + ? cycleExecLogFile(dir, cycleRecord.cycleIndex) + : path.join(dir, `cycle-${cycleRecord.cycleIndex}-exec-retry-${attemptIndex}.log`); + fs.writeFileSync(execLog, execResult.report, "utf8"); + const relativeLog = path.relative(dir, execLog); + cycleRecord.executorAttemptLogs = [...priorLogs, relativeLog]; + cycleRecord.executorLogFile = relativeLog; + cycleRecord.executorExitCode = execResult.exitCode ?? undefined; + cycleRecord.executorReport = execResult.report.slice(0, 2000); +} + +// --------------------------------------------------------------------------- +// workflow start +// --------------------------------------------------------------------------- + +export interface StartWorkflowOpts { + planInput: string; // file path or "-" for stdin + executor?: string; + maxCycles?: number; + cwd?: string; +} + +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 + let planRaw: string; + if (opts.planInput === "-") { + planRaw = fs.readFileSync(0, "utf8"); + } else { + if (!fs.existsSync(opts.planInput)) { + throw new WorkflowEngineError(`Plan file not found: ${opts.planInput}`); + } + planRaw = fs.readFileSync(opts.planInput, "utf8"); + } + + let plan: unknown; + try { + plan = JSON.parse(planRaw); + } catch { + throw new WorkflowEngineError("Plan is not valid JSON."); + } + validateWorkflowPlan(plan); + const typedPlan = plan as WorkflowPlanV1; + + // 3. Resolve config + const config = resolveWorkflowConfig(repoRoot, { + ...(opts.executor ? { executor: opts.executor as import("./workflow-types.js").ExecutorKind } : {}), + ...(opts.maxCycles !== undefined ? { maxCycles: opts.maxCycles } : {}), + }); + + // 4. Probe executor + await probeExecutor(config.executor, config.executorConfig); + + // 5. Require clean working tree + let isClean: boolean; + try { + isClean = gitIsClean(repoRoot); + } catch (err) { + throw new WorkflowEngineError((err as Error).message); + } + if (!isClean) { + throw new WorkflowEngineError( + "Working tree has uncommitted changes. Commit or stash them before starting a workflow.", + 4, + ); + } + + // 6. Acquire lock (one active workflow per repo) + const runId = generateRunId(); + try { + acquireLock(repoRoot, runId); + } catch (err) { + if (err instanceof WorkflowLockError) { + throw new WorkflowEngineError(err.message, 4); + } + throw err; + } + + const dir = makeRunDir(repoRoot, runId); + fs.mkdirSync(dir, { recursive: true }); + + const baselineHead = gitHead(repoRoot); + const state = initWorkflowState({ + runId, + repoRoot, + baselineHead, + executor: config.executor, + plan: typedPlan, + maxCycles: config.maxCycles, + timeoutSeconds: config.timeoutSeconds, + }); + state.executorConfig = config.executorConfig; + state.timeoutSeconds = config.timeoutSeconds; + state.enginePid = process.pid; + writeState(dir, state); + appendEvent(dir, { + kind: "workflow_started", + runId, + timestamp: state.createdAt, + data: { executor: config.executor, maxCycles: config.maxCycles, baselineHead }, + }); + + process.stdout.write(`workflow started: ${runId}\n`); + process.stdout.write(` executor: ${config.executor}\n`); + process.stdout.write(` max-cycles: ${config.maxCycles}\n`); + process.stdout.write(` baseline: ${baselineHead}\n`); + process.stdout.write(` run dir: ${dir}\n\n`); + + // 7. Start first execution cycle + const cycleIndex = 1; + const cycleRecord: CycleRecord = { cycleIndex, startedAt: nowIso() }; + state.cycles.push(cycleRecord); + state.currentCycle = cycleIndex; + state.status = "executing"; + state.updatedAt = nowIso(); + writeState(dir, state); + + appendEvent(dir, { kind: "cycle_started", runId, timestamp: nowIso(), cycleIndex }); + appendEvent(dir, { kind: "executor_started", runId, timestamp: nowIso(), cycleIndex, data: { executor: config.executor } }); + + const adapter = createExecutorAdapter(config.executor); + const { ac, cleanup } = createManagedController(state.timeoutSeconds); + let execResult; + try { + execResult = await adapter.start({ + repoRoot, + plan: typedPlan, + runDir: dir, + cycleIndex, + config: config.executorConfig, + signal: ac.signal, + }); + } catch (err) { + cleanup(); + transitionToTerminal(state, dir, "blocked", `Executor failed to start: ${(err as Error).message}`); + throw new WorkflowEngineError(`Executor error: ${(err as Error).message}`); + } + cleanup(); + + persistExecutorAttempt(dir, cycleRecord, execResult); + if (execResult.sessionHandle) { + state.sessionHandle = execResult.sessionHandle; + if (execResult.sessionHandle.kind === "agy" && execResult.sessionHandle.degraded) { + state.agyDegraded = true; + } + } + + appendEvent(dir, { + kind: "executor_completed", + runId, + timestamp: nowIso(), + cycleIndex, + data: { + exitCode: execResult.exitCode, + durationMs: execResult.durationMs, + failed: execResult.failed, + ...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), + }, + }); + + if (execResult.failed) { + transitionToTerminal(state, dir, "blocked", `Executor exited with failure: ${execResult.failureReason ?? "exit code " + String(execResult.exitCode)}`); + throw new WorkflowEngineError("Executor failed."); + } + + // Transition to awaiting_review + state.status = "awaiting_review"; + state.enginePid = undefined; + state.updatedAt = nowIso(); + writeStateSafety(dir, state); + + process.stdout.write(`cycle ${cycleIndex} execution completed\n`); + process.stdout.write(`status: awaiting_review\n`); + process.stdout.write(`next: agent-workflow review --run ${runId}\n`); + + emitWorkflowMeta({ runId, status: state.status, cycleIndex, executor: config.executor }); + return { runId }; +} + +// --------------------------------------------------------------------------- +// workflow review +// --------------------------------------------------------------------------- + +export interface ReviewWorkflowOpts { + runId: string; + cwd?: string; +} + +export async function reviewWorkflow(opts: ReviewWorkflowOpts): Promise { + const { state, dir } = requireState(opts.runId); + + if (state.status !== "awaiting_review") { + throw new WorkflowEngineError( + `Run '${opts.runId}' is in status '${state.status}', not awaiting_review.`, + 2, + ); + } + + const cycleIndex = state.currentCycle; + const cycleRecord = state.cycles.find((c) => c.cycleIndex === cycleIndex)!; + + state.enginePid = process.pid; + writeStateSafety(dir, state); + + // 1. Generate diff from baseline HEAD + let diff: string; + try { + diff = gitDiffFromHead(state.repoRoot, state.baselineHead); + } catch (err) { + transitionToTerminal(state, dir, "blocked", `Failed to generate diff: ${(err as Error).message}`); + throw new WorkflowEngineError((err as Error).message); + } + const diffFile = cycleDiffFile(dir, cycleIndex); + fs.writeFileSync(diffFile, diff, "utf8"); + cycleRecord.diffFile = path.relative(dir, diffFile); + + // 2. Check for HEAD drift (executor committed something) + const currentHead = gitHead(state.repoRoot); + if (currentHead !== state.baselineHead) { + // Check if intermediate commits happened + transitionToTerminal( + state, + dir, + "blocked", + `HEAD changed from baseline (${state.baselineHead.slice(0, 8)}) to ${currentHead.slice(0, 8)} — executor may have committed. Manual review required.`, + ); + throw new WorkflowEngineError("HEAD changed unexpectedly. Workflow blocked."); + } + + // 3. Check out-of-scope files + const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope); + if (outOfScope.length > 0) { + const scopeViolations = makeScopeViolations(outOfScope); + const previousCycle = state.cycles.find((cycle) => cycle.cycleIndex === cycleIndex - 1); + if (previousCycle && sameScopeViolations(previousCycle.scopeViolations, scopeViolations)) { + transitionToTerminal( + state, + dir, + "needs_human", + `Scope remediation did not converge: ${outOfScope.sort().join(", ")}`, + { cycleIndex, scopeViolations }, + ); + emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex, scopeViolations }); + throw new WorkflowEngineError("Scope remediation did not converge.", 3); + } + + cycleRecord.scopeViolations = scopeViolations; + state.status = "awaiting_scope_resolution"; + state.enginePid = undefined; + state.updatedAt = nowIso(); + writeStateSafety(dir, state); + appendEvent(dir, { + kind: "scope_violation_detected", + runId: state.runId, + timestamp: state.updatedAt, + cycleIndex, + data: { scopeViolations }, + }); + + process.stdout.write(`scope violations detected (cycle ${cycleIndex})\n`); + for (const violation of scopeViolations) { + process.stdout.write(` ${violation.id}: ${violation.path}\n`); + } + process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input \n`); + emitWorkflowMeta({ + runId: state.runId, + status: state.status, + cycleIndex, + scopeViolations, + }); + return; + } + + const generatedAt = nowIso(); + appendEvent(dir, { kind: "review_started", runId: state.runId, timestamp: generatedAt, cycleIndex }); + + // 4. Persist all evidence needed by the Codex host. The CLI does not invoke + // another reviewer: Codex owns review and final acceptance. + const hostReviewFile = cycleHostReviewFile(dir, cycleIndex); + const bundle: HostReviewBundleV1 = { + version: "1", + runId: state.runId, + cycleIndex, + repoRoot: state.repoRoot, + baselineHead: state.baselineHead, + generatedAt, + plan: state.plan, + diffFile: diffFile, + ...(cycleRecord.executorLogFile + ? { executorLogFile: path.join(dir, cycleRecord.executorLogFile) } + : {}), + ...(cycleRecord.executorReport ? { executorReport: cycleRecord.executorReport } : {}), + verificationCommands: state.plan.verificationCommands, + }; + fs.writeFileSync(hostReviewFile, JSON.stringify(bundle, null, 2), "utf8"); + cycleRecord.hostReviewFile = path.relative(dir, hostReviewFile); + cycleRecord.hostReviewReadyAt = generatedAt; + + appendEvent(dir, { + kind: "review_completed", + runId: state.runId, + timestamp: generatedAt, + cycleIndex, + data: { hostReviewFile, diffFile }, + }); + + state.status = "awaiting_host"; + state.enginePid = undefined; + state.updatedAt = generatedAt; + writeStateSafety(dir, state); + + process.stdout.write(`Codex review bundle ready (cycle ${cycleIndex})\n`); + process.stdout.write(` bundle: ${hostReviewFile}\n`); + process.stdout.write(` diff: ${diffFile}\n`); + process.stdout.write(`next: agent-workflow decide --run ${opts.runId} --input \n`); + emitWorkflowMeta({ + runId: state.runId, + status: state.status, + cycleIndex, + hostReviewFile, + diffFile, + }); +} + +// --------------------------------------------------------------------------- +// workflow decide +// --------------------------------------------------------------------------- + +export interface DecideWorkflowOpts { + runId: string; + decisionInput: string; // file path or "-" for stdin + cwd?: string; +} + +export async function decideWorkflow(opts: DecideWorkflowOpts): Promise { + const { state, dir } = requireState(opts.runId); + + const resolvingScope = state.status === "awaiting_scope_resolution"; + if (state.status !== "awaiting_host" && !resolvingScope) { + throw new WorkflowEngineError( + `Run '${opts.runId}' is in status '${state.status}', not awaiting_host or awaiting_scope_resolution.`, + 2, + ); + } + + // Load decision + let decisionRaw: string; + if (opts.decisionInput === "-") { + decisionRaw = fs.readFileSync(0, "utf8"); + } else { + if (!fs.existsSync(opts.decisionInput)) { + throw new WorkflowEngineError(`Decision file not found: ${opts.decisionInput}`); + } + decisionRaw = fs.readFileSync(opts.decisionInput, "utf8"); + } + + let decisionObj: unknown; + try { + decisionObj = JSON.parse(decisionRaw); + } catch { + throw new WorkflowEngineError("Decision is not valid JSON.", 2); + } + validateHostDecision(decisionObj); + const decision = decisionObj as HostDecisionV1; + + const cycleIndex = state.currentCycle; + const cycleRecord = state.cycles.find((c) => c.cycleIndex === cycleIndex)!; + + if (resolvingScope && decision.outcome === "accept") { + throw new WorkflowEngineError("Cannot accept while scope violations remain. Use fix or needs_human.", 4); + } + if (resolvingScope && decision.outcome === "fix") { + const expectedIds = new Set((cycleRecord.scopeViolations ?? []).map((violation) => violation.id)); + const submittedIds = decision.findingDecisions.map((finding) => finding.findingId); + const acceptedIds = new Set( + decision.findingDecisions + .filter((finding) => finding.disposition === "accept") + .map((finding) => finding.findingId), + ); + const unknownIds = decision.findingDecisions + .map((finding) => finding.findingId) + .filter((id) => !expectedIds.has(id)); + const missingIds = [...expectedIds].filter((id) => !acceptedIds.has(id)); + const duplicateIds = submittedIds.filter((id, index) => submittedIds.indexOf(id) !== index); + if (expectedIds.size === 0 || unknownIds.length > 0 || missingIds.length > 0 || duplicateIds.length > 0) { + throw new WorkflowEngineError( + `Scope fix must accept every current violation exactly once. Missing: ${missingIds.join(", ") || "none"}; unknown: ${unknownIds.join(", ") || "none"}; duplicate: ${[...new Set(duplicateIds)].join(", ") || "none"}.`, + 4, + ); + } + } + if (!resolvingScope && decision.outcome === "fix") { + const accepted = decision.findingDecisions.filter((finding) => finding.disposition === "accept"); + if (accepted.length === 0) { + throw new WorkflowEngineError("Fix decision must accept at least one Codex finding.", 4); + } + const missingSummary = accepted + .filter((finding) => !finding.summary?.trim()) + .map((finding) => finding.findingId); + if (missingSummary.length > 0) { + throw new WorkflowEngineError( + `Accepted Codex findings require a non-empty summary: ${missingSummary.join(", ")}.`, + 4, + ); + } + } + if (!resolvingScope && decision.outcome === "accept" + && decision.findingDecisions.some((finding) => finding.disposition === "accept")) { + throw new WorkflowEngineError("Accept decision cannot contain findings accepted for fixing.", 4); + } + + // Save decision file + const decFile = cycleDecisionFile(dir, cycleIndex); + fs.writeFileSync(decFile, JSON.stringify(decision, null, 2), "utf8"); + cycleRecord.decisionFile = path.relative(dir, decFile); + cycleRecord.decisionOutcome = decision.outcome; + cycleRecord.completedAt = nowIso(); + + appendEvent(dir, { + kind: "decision_received", + runId: state.runId, + timestamp: nowIso(), + cycleIndex, + data: { outcome: decision.outcome, reason: decision.reason }, + }); + + // Handle decision outcomes + if (decision.outcome === "needs_human") { + transitionToTerminal( + state, + dir, + "needs_human", + decision.reason ?? "Codex determined human intervention is required.", + { cycleIndex }, + ); + process.stdout.write(`workflow stopped: needs_human\n`); + process.stdout.write(`reason: ${decision.reason ?? "human intervention required"}\n`); + emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex }); + process.exit(3); + } + + if (decision.outcome === "accept") { + // Validate accept conditions + const acceptError = validateAcceptConditions(state, dir, cycleIndex, decision); + if (acceptError) { + throw new WorkflowEngineError(`Cannot accept: ${acceptError}`, 4); + } + transitionToTerminal(state, dir, "completed", "Codex accepted — all conditions met.", { cycleIndex }); + process.stdout.write(`workflow completed: accepted\n`); + emitWorkflowMeta({ runId: state.runId, status: "completed", cycleIndex }); + process.exit(0); + } + + // decision.outcome === "fix" — start next cycle + const nextCycle = cycleIndex + 1; + if (nextCycle > state.maxCycles) { + transitionToTerminal( + state, + dir, + "budget_exhausted", + `Reached maximum cycles (${state.maxCycles}). Further execution not allowed.`, + ); + process.stdout.write(`workflow stopped: budget_exhausted (max cycles: ${state.maxCycles})\n`); + emitWorkflowMeta({ runId: state.runId, status: "budget_exhausted", cycleIndex }); + process.exit(1); + } + + // Check convergence using finding signatures from all cycles with review data + const convergenceError = resolvingScope ? undefined : checkWorkflowConvergence(state, dir); + if (convergenceError) { + transitionToTerminal(state, dir, "needs_human", convergenceError, { cycleIndex }); + process.stdout.write(`workflow stopped: needs_human (non-convergence detected)\n`); + process.stdout.write(`reason: ${convergenceError}\n`); + emitWorkflowMeta({ runId: state.runId, status: "needs_human", cycleIndex }); + process.exit(3); + } + + // Build fix prompt from accepted findings + const acceptedFindings = resolvingScope + ? buildScopeResolutionText(cycleRecord.scopeViolations ?? []) + : buildAcceptedFindingsText(decision, state, dir, cycleIndex); + + // Start next cycle + const nextCycleRecord: CycleRecord = { cycleIndex: nextCycle, startedAt: nowIso() }; + state.cycles.push(nextCycleRecord); + state.currentCycle = nextCycle; + state.status = "executing"; + state.updatedAt = nowIso(); + state.enginePid = process.pid; + writeStateSafety(dir, state); + + appendEvent(dir, { kind: "cycle_started", runId: state.runId, timestamp: nowIso(), cycleIndex: nextCycle }); + if (resolvingScope) { + appendEvent(dir, { + kind: "scope_resolution_started", + runId: state.runId, + timestamp: nowIso(), + cycleIndex: nextCycle, + data: { scopeViolations: cycleRecord.scopeViolations }, + }); + } + + await resumeExecutorCycle(state, dir, nextCycleRecord, acceptedFindings); +} + +async function resumeExecutorCycle( + state: WorkflowState, + dir: string, + cycleRecord: CycleRecord, + acceptedFindings: string, +): Promise { + const cycleIndex = cycleRecord.cycleIndex; + if (!state.sessionHandle) { + transitionToTerminal(state, dir, "blocked", "No session handle available for resume.", { cycleIndex }); + throw new WorkflowEngineError("Cannot resume: no session handle. Workflow blocked."); + } + + const adapter = createExecutorAdapter(state.executor); + const { ac, cleanup } = createManagedController(state.timeoutSeconds); + let execResult; + try { + execResult = await adapter.resume({ + repoRoot: state.repoRoot, + plan: state.plan, + acceptedFindings, + handle: state.sessionHandle, + runDir: dir, + cycleIndex, + config: state.executorConfig ?? {}, + signal: ac.signal, + }); + } catch (err) { + cleanup(); + transitionToTerminal( + state, + dir, + "blocked", + `Executor resume failed: ${(err as Error).message}`, + { cycleIndex }, + ); + throw new WorkflowEngineError(`Executor resume error: ${(err as Error).message}`); + } + cleanup(); + + persistExecutorAttempt(dir, cycleRecord, execResult); + if (execResult.sessionHandle) state.sessionHandle = execResult.sessionHandle; + + appendEvent(dir, { + kind: "executor_completed", + runId: state.runId, + timestamp: nowIso(), + cycleIndex, + data: { + exitCode: execResult.exitCode, + durationMs: execResult.durationMs, + failed: execResult.failed, + ...(execResult.failureReason ? { failureReason: execResult.failureReason } : {}), + }, + }); + + if (execResult.failed) { + transitionToTerminal( + state, + dir, + "blocked", + `Executor failed to resume: ${execResult.failureReason ?? "exit code " + String(execResult.exitCode)}`, + { cycleIndex }, + ); + throw new WorkflowEngineError("Executor failed."); + } + + state.status = "awaiting_review"; + state.enginePid = undefined; + state.updatedAt = nowIso(); + writeStateSafety(dir, state); + + process.stdout.write(`cycle ${cycleIndex} execution completed\n`); + process.stdout.write(`status: awaiting_review\n`); + process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`); + emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex }); +} + +function validateAcceptConditions(state: WorkflowState, dir: string, cycleIndex: number, decision: HostDecisionV1): string | undefined { + // 1. Codex must have received a persisted review bundle for this cycle. + const cycle = state.cycles.find((c) => c.cycleIndex === cycleIndex); + if (!cycle?.hostReviewFile) return "No Codex review bundle for this cycle."; + if (!fs.existsSync(path.join(dir, cycle.hostReviewFile))) { + 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)}).`; + } + + // 3. All files must be in scope + const outOfScope = checkFilesInScope(state.repoRoot, state.baselineHead, state.plan.scope); + if (outOfScope.length > 0) { + return `Out-of-scope files: ${outOfScope.join(", ")}`; + } + + // 4. Verification commands must have evidence and succeed + if (state.plan.verificationCommands && state.plan.verificationCommands.length > 0) { + if (!decision.verificationEvidence || decision.verificationEvidence.length === 0) { + return "Accept decision requires verificationEvidence when plan specifies verificationCommands."; + } + for (const cmd of state.plan.verificationCommands) { + if (!cmd || cmd.length === 0) continue; + try { + execFileSync(cmd[0], cmd.slice(1), { + cwd: state.repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: (state.timeoutSeconds ?? 300) * 1000, + }); + } catch (err: any) { + return `Verification command failed: ${cmd.join(" ")}\nError: ${err.message}`; + } + } + } + + return undefined; +} + +function checkWorkflowConvergence(state: WorkflowState, dir: string): string | undefined { + const history: FindingSignature[][] = []; + for (const cycle of state.cycles) { + if (!cycle.decisionFile) continue; + const decisionPath = path.join(dir, cycle.decisionFile); + if (!fs.existsSync(decisionPath)) continue; + try { + const priorDecision = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as HostDecisionV1; + history.push(decisionSignatures(priorDecision.findingDecisions)); + } catch { /* skip corrupt files */ } + } + + const result = checkConvergence(history); + if (!result.converging) { + if (result.persistentFindings?.length) { + return `Findings not converging: the same ${result.persistentFindings.length} issue(s) persist across consecutive cycles. Human review required.`; + } + if (result.oscillatingFindings?.length) { + return `Findings oscillating: ${result.oscillatingFindings.length} issue(s) disappeared and reappeared. Human review required.`; + } + return "Findings not converging. Human review required."; + } + return undefined; +} + +function buildAcceptedFindingsText( + decision: HostDecisionV1, + _state: WorkflowState, + _dir: string, + _cycleIndex: number, +): string { + const accepted = decision.findingDecisions.filter((d) => d.disposition === "accept"); + + const lines = ["# Codex Review Findings", "", "Fix only the following issues accepted by the Codex host:"]; + for (const d of accepted) { + lines.push(`\n### Finding ${d.findingId}`); + if (d.path) lines.push(`- Path: ${d.path}`); + lines.push(`- Issue: ${d.summary}`); + if (d.rationale) lines.push(`- Rationale: ${d.rationale}`); + } + + return lines.join("\n"); +} + +function buildScopeResolutionText(violations: ScopeViolation[]): string { + const lines = [ + "# Scope Remediation", + "", + "The frozen scope gate found these exact out-of-scope paths:", + ]; + for (const violation of violations) { + lines.push(`- ${violation.id}: ${violation.path}`); + } + lines.push( + "", + "Inspect every listed path. Remove only temporary artifacts you created. If a path contains an intentional change, restore it to the baseline behavior without using git reset, git checkout, or git restore.", + "Do not modify any other out-of-scope path and do not broaden the plan scope.", + "Use an OS temporary directory for further investigation. For SQLite files, clean the database plus -wal, -shm, and -journal sidecars.", + "Before finishing, run git status --porcelain --untracked-files=all and confirm no path outside the allowed scope remains.", + ); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// workflow retry-review +// --------------------------------------------------------------------------- + +export interface RetryReviewWorkflowOpts { + runId: string; +} + +export function retryReviewWorkflow(opts: RetryReviewWorkflowOpts): void { + const { state, dir } = requireState(opts.runId); + if (state.status !== "awaiting_scope_resolution") { + throw new WorkflowEngineError( + `Run '${opts.runId}' cannot retry review from status '${state.status}'. Only scope-resolution runs are eligible.`, + 4, + ); + } + + 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 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(", ")}.`, + 4, + ); + } + + const cycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle); + if (cycle) { + delete cycle.scopeViolations; + delete cycle.hostReviewFile; + delete cycle.hostReviewReadyAt; + } + state.status = "awaiting_review"; + state.enginePid = undefined; + delete state.stopReason; + delete state.stopDescription; + state.updatedAt = nowIso(); + writeState(dir, state); + appendEvent(dir, { + kind: "review_retried", + runId: state.runId, + timestamp: state.updatedAt, + cycleIndex: state.currentCycle, + data: { + previousStatus: "awaiting_scope_resolution", + }, + }); + + process.stdout.write(`workflow review retry enabled: ${state.runId}\n`); + process.stdout.write(`status: awaiting_review\n`); + process.stdout.write(`next: agent-workflow review --run ${state.runId}\n`); + emitWorkflowMeta({ runId: state.runId, status: state.status, cycleIndex: state.currentCycle }); +} + +// --------------------------------------------------------------------------- +// workflow retry-execute +// --------------------------------------------------------------------------- + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +export interface RetryExecuteWorkflowOpts { + runId: string; +} + +export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Promise { + const { state, dir } = requireState(opts.runId); + const staleExecution = state.status === "executing" + && state.enginePid !== undefined + && !isProcessAlive(state.enginePid); + const retryableFailure = staleExecution || (state.status === "blocked" + && (state.stopDescription?.startsWith("Executor exited with failure:") === true + || state.stopDescription?.startsWith("Executor failed to resume:") === true + || state.stopDescription?.startsWith("Executor resume failed:") === true)); + if (!retryableFailure) { + throw new WorkflowEngineError( + `Run '${opts.runId}' cannot retry execution from status '${state.status}'. Only failed executor runs with a resumable session are eligible.`, + 4, + ); + } + + const cycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle); + const previousCycle = state.cycles.find((record) => record.cycleIndex === state.currentCycle - 1); + if (!cycle || cycle.diffFile || cycle.hostReviewFile || cycle.decisionFile || cycle.scopeViolations) { + throw new WorkflowEngineError("Cannot retry execution: the failed cycle already contains review artifacts.", 4); + } + if (!state.sessionHandle) { + throw new WorkflowEngineError("Cannot retry execution: no executor session handle is available.", 4); + } + const recoveringInitialCycle = state.currentCycle === 1; + if (!recoveringInitialCycle && (!previousCycle?.decisionFile || previousCycle.decisionOutcome !== "fix")) { + throw new WorkflowEngineError("Cannot retry execution: the preceding fix decision is missing.", 4); + } + 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 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)) + : outOfScope; + if (unexpectedOutOfScope.length > 0) { + throw new WorkflowEngineError( + `Cannot retry execution while unexpected out-of-scope files remain: ${unexpectedOutOfScope.sort().join(", ")}.`, + 4, + ); + } + + let acceptedFindings: string; + if (recoveringInitialCycle) { + acceptedFindings = "# Execution Recovery\n\nContinue the original implementation from the current working tree."; + } else { + const decisionPath = path.join(dir, previousCycle!.decisionFile!); + let decision: HostDecisionV1; + try { + const raw = JSON.parse(fs.readFileSync(decisionPath, "utf8")) as unknown; + validateHostDecision(raw); + decision = raw; + } catch (err) { + throw new WorkflowEngineError(`Cannot retry execution: failed to read preceding decision: ${(err as Error).message}`, 4); + } + acceptedFindings = resolvingScope + ? buildScopeResolutionText(previousCycle!.scopeViolations ?? []) + : buildAcceptedFindingsText(decision, state, dir, previousCycle!.cycleIndex); + } + + try { + acquireLock(state.repoRoot, state.runId); + } catch (err) { + if (err instanceof WorkflowLockError) throw new WorkflowEngineError(err.message, 4); + throw err; + } + + state.status = "executing"; + delete state.stopReason; + delete state.stopDescription; + state.enginePid = process.pid; + state.updatedAt = nowIso(); + writeState(dir, state); + + // Determine recovery mode: stale_execution trumps initial_continuation + let recoveryMode: string; + if (staleExecution) { + recoveryMode = "stale_execution"; + } else if (recoveringInitialCycle) { + recoveryMode = "initial_continuation"; + } else { + recoveryMode = "fix_resume"; + } + + appendEvent(dir, { + kind: "executor_retried", + runId: state.runId, + timestamp: state.updatedAt, + cycleIndex: state.currentCycle, + data: { + executor: state.executor, + recoveryMode, + }, + }); + + process.stdout.write(`retrying executor cycle ${state.currentCycle} with the existing ${state.executor} session\n`); + await resumeExecutorCycle(state, dir, cycle, acceptedFindings); +} + +// --------------------------------------------------------------------------- +// workflow status +// --------------------------------------------------------------------------- + +export interface StatusWorkflowOpts { + runId: string; + json?: boolean; +} + +export function statusWorkflow(opts: StatusWorkflowOpts): void { + const { state, dir } = requireState(opts.runId); + const cycle = state.cycles.find((c) => c.cycleIndex === state.currentCycle); + + const output: WorkflowStatusOutput = { + runId: state.runId, + status: state.status, + executor: state.executor, + currentCycle: state.currentCycle, + maxCycles: state.maxCycles, + remainingCycles: Math.max(0, state.maxCycles - state.currentCycle), + sessionHandleKind: state.sessionHandle?.kind, + degradedResume: state.agyDegraded, + runDir: dir, + diffFile: cycle?.diffFile ? path.join(dir, cycle.diffFile) : undefined, + executorLogFile: cycle?.executorLogFile ? path.join(dir, cycle.executorLogFile) : undefined, + hostReviewFile: cycle?.hostReviewFile ? path.join(dir, cycle.hostReviewFile) : undefined, + scopeViolations: state.status === "awaiting_scope_resolution" ? cycle?.scopeViolations : undefined, + stopReason: state.stopReason, + stopDescription: state.stopDescription, + baselineHead: state.baselineHead, + repoRoot: state.repoRoot, + createdAt: state.createdAt, + updatedAt: state.updatedAt, + }; + + if (opts.json) { + process.stdout.write(JSON.stringify(output, null, 2) + "\n"); + return; + } + + const lw = 20; + const p = (label: string, value: string) => + ` ${(label + ":").padEnd(lw)} ${value}`; + + const lines = [ + "── agent-workflow ─────────────────────", + p("Run ID", state.runId), + p("Status", state.status.toUpperCase()), + p("Executor", state.executor), + p("Cycle", `${state.currentCycle} / ${state.maxCycles} (${output.remainingCycles} remaining)`), + ]; + + if (state.sessionHandle) { + const kind = state.sessionHandle.kind; + const extra = kind === "agy" && state.agyDegraded ? " [degraded-continue]" : ""; + lines.push(p("Session handle", kind + extra)); + } + + if (cycle?.hostReviewFile) lines.push(p("Host review", path.join(dir, cycle.hostReviewFile))); + if (state.status === "awaiting_scope_resolution" && cycle?.scopeViolations?.length) { + lines.push(p("Scope violations", String(cycle.scopeViolations.length))); + for (const violation of cycle.scopeViolations) { + lines.push(` ${violation.id}: ${violation.path}`); + } + } + 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))); + lines.push(p("Created", state.createdAt)); + lines.push(p("Updated", state.updatedAt)); + lines.push("─".repeat(46)); + + process.stdout.write(lines.join("\n") + "\n"); + emitWorkflowMeta(output as unknown as Record); +} + +// --------------------------------------------------------------------------- +// workflow abort +// --------------------------------------------------------------------------- + +export interface AbortWorkflowOpts { + runId: string; + reason: string; +} + +export function abortWorkflow(opts: AbortWorkflowOpts): void { + const { state, dir } = requireState(opts.runId); + + if (["completed", "aborted", "needs_human", "blocked", "budget_exhausted"].includes(state.status)) { + process.stdout.write(`Run '${opts.runId}' is already in terminal state: ${state.status}\n`); + return; + } + + const pid = state.enginePid; + transitionToTerminal(state, dir, "aborted", opts.reason, { abortedAt: nowIso() }); + + if (pid) { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Ignore if process is already dead + } + } + + process.stdout.write(`workflow aborted: ${opts.runId}\nreason: ${opts.reason}\n`); + emitWorkflowMeta({ runId: opts.runId, status: "aborted", reason: opts.reason }); +} diff --git a/src/workflow-executor.test.ts b/src/workflow-executor.test.ts new file mode 100644 index 0000000..92c2bad --- /dev/null +++ b/src/workflow-executor.test.ts @@ -0,0 +1,471 @@ +/** + * Unit tests for executor adapters (Sprint 2). + * + * Uses fake CLI binaries to test argument escaping, timeout/cancel behavior, + * session handle detection, and degraded-resume logic. + * + * These tests mock out the actual executors and verify the adapter logic + * without running real AI agents. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createExecutorAdapter } from "./workflow-executor.js"; +import type { WorkflowPlanV1 } from "./workflow-types.js"; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-executor-test-")); +} + +function cleanupDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function makeValidPlan(): WorkflowPlanV1 { + return { + version: "1", + title: "Test plan", + planMarkdown: "Do something.", + scope: ["src/"], + acceptanceCriteria: ["Tests pass"], + verificationCommands: [["npm", "test"]], + }; +} + +// --------------------------------------------------------------------------- +// Fake CLI builder +// --------------------------------------------------------------------------- + +/** + * Creates a fake CLI script that: + * 1. Logs its arguments to a file + * 2. Exits with the given code + * 3. Optionally writes JSON with a session_id/conversation_id to stdout + */ +function writeFakeCli(dir: string, opts: { + exitCode?: number; + stdout?: string; + argsFile?: string; +}): string { + const scriptPath = path.join(dir, "fake-cli.sh"); + const argsFile = opts.argsFile ?? path.join(dir, "args.txt"); + const exitCode = opts.exitCode ?? 0; + const stdout = opts.stdout ?? ""; + + const script = `#!/bin/bash +echo "$@" >> "${argsFile}" +echo '${stdout.replace(/'/g, "'\\''")}' +exit ${exitCode} +`; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + return scriptPath; +} + +// --------------------------------------------------------------------------- +// Reasonix adapter tests +// --------------------------------------------------------------------------- + +describe("ReasonixAdapter", () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { cleanupDir(tmpDir); }); + + it("probe returns false when binary not found", async () => { + const adapter = createExecutorAdapter("reasonix"); + // Override with a non-existent binary + const result = await adapter.probe(); + // We expect false since 'reasonix' is not installed in CI + expect(typeof result).toBe("boolean"); + }); + + it("start builds args with --dir and no --file flag", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { argsFile }); + + const adapter = createExecutorAdapter("reasonix"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + // Should have been called with 'run --dir ... ' + expect(capturedArgs).toContain("run"); + expect(capturedArgs).toContain("--dir"); + expect(capturedArgs).not.toContain("--file"); + expect(result.exitCode).toBe(0); + expect(result.failed).toBe(false); + }); + + it("resume builds args with --resume", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { argsFile }); + const sessionFile = path.join(tmpDir, "session.jsonl"); + fs.writeFileSync(sessionFile, "{}\n"); + + const adapter = createExecutorAdapter("reasonix"); + const result = await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: "Fix null pointer at src/index.ts", + handle: { kind: "reasonix", sessionFilePath: sessionFile }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("--resume"); + expect(capturedArgs).toContain(sessionFile); + expect(result.exitCode).toBe(0); + expect(result.sessionHandle?.kind).toBe("reasonix"); + }); + + it("reports failure when binary exits with non-zero", async () => { + const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 }); + + const adapter = createExecutorAdapter("reasonix"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + expect(result.exitCode).toBe(1); + expect(result.failed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Claude adapter tests +// --------------------------------------------------------------------------- + +describe("ClaudeAdapter", () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { cleanupDir(tmpDir); }); + + it("start includes --session-id and -p flags", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { + argsFile, + stdout: '{"session_id": "test-session-uuid"}', + }); + + const adapter = createExecutorAdapter("claude"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("-p"); + expect(capturedArgs).toContain("--output-format"); + expect(capturedArgs).toContain("--session-id"); + expect(result.sessionHandle?.kind).toBe("claude"); + }); + + it("resume uses --resume with captured session ID", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { argsFile }); + + const adapter = createExecutorAdapter("claude"); + const result = await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: "Fix auth bypass", + handle: { kind: "claude", sessionId: "captured-session-id" }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("--resume"); + expect(capturedArgs).toContain("captured-session-id"); + expect(result.sessionHandle?.kind).toBe("claude"); + }); + + it("extracts session_id from JSON output", async () => { + const fakeBin = writeFakeCli(tmpDir, { + stdout: '{"session_id": "extracted-id-123", "result": "done"}', + }); + + const adapter = createExecutorAdapter("claude"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + if (result.sessionHandle?.kind === "claude") { + expect(result.sessionHandle.sessionId).toBe("extracted-id-123"); + } + }); +}); + +// --------------------------------------------------------------------------- +// Agy adapter tests +// --------------------------------------------------------------------------- + +describe("AgyAdapter", () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { cleanupDir(tmpDir); }); + + it("start uses --print and --mode accept-edits", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { + argsFile, + stdout: '{"conversation_id": "agy-conv-id-456"}', + }); + + const adapter = createExecutorAdapter("agy"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("--print"); + expect(capturedArgs).toContain("--mode accept-edits"); + + if (result.sessionHandle?.kind === "agy") { + expect(result.sessionHandle.conversationId).toBe("agy-conv-id-456"); + expect(result.sessionHandle.degraded).toBe(false); + } + }); + + it("falls back to degraded mode when no conversation_id", async () => { + const fakeBin = writeFakeCli(tmpDir, { stdout: "Agy output without JSON" }); + + const adapter = createExecutorAdapter("agy"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + if (result.sessionHandle?.kind === "agy") { + expect(result.sessionHandle.conversationId).toBeUndefined(); + expect(result.sessionHandle.degraded).toBe(true); + } + }); + + it("resume with conversation_id uses --conversation flag", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { argsFile }); + + const adapter = createExecutorAdapter("agy"); + await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: "Fix the bug", + handle: { kind: "agy", conversationId: "known-conv-id", degraded: false }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("--conversation"); + expect(capturedArgs).toContain("known-conv-id"); + }); + + it("degraded resume uses --continue flag", async () => { + const argsFile = path.join(tmpDir, "args.txt"); + const fakeBin = writeFakeCli(tmpDir, { argsFile }); + + const adapter = createExecutorAdapter("agy"); + await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: "Fix the bug", + handle: { kind: "agy", degraded: true }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const capturedArgs = fs.existsSync(argsFile) ? fs.readFileSync(argsFile, "utf8") : ""; + expect(capturedArgs).toContain("--continue"); + }); +}); + +// --------------------------------------------------------------------------- +// Safety constraints in prompts +// --------------------------------------------------------------------------- + +describe("Safety constraints in executor prompts", () => { + let tmpDir: string; + + beforeEach(() => { tmpDir = makeTmpDir(); }); + afterEach(() => { cleanupDir(tmpDir); }); + + it("start prompt file contains no-commit constraint", async () => { + const fakeBin = writeFakeCli(tmpDir, {}); + + const adapter = createExecutorAdapter("reasonix"); + await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + // Find the generated prompt file + const promptFile = path.join(tmpDir, "cycle-1-prompt.txt"); + if (fs.existsSync(promptFile)) { + const content = fs.readFileSync(promptFile, "utf8"); + expect(content).toContain("DO NOT run git commit"); + expect(content).toContain("mktemp"); + expect(content).toContain("-wal, -shm, and -journal"); + expect(content).toContain("git status --porcelain --untracked-files=all"); + expect(content).toContain("DO NOT"); + } + }); + + it("resume prompt contains only accepted findings, not all findings", async () => { + const fakeBin = writeFakeCli(tmpDir, {}); + const sessionFile = path.join(tmpDir, "session.jsonl"); + fs.writeFileSync(sessionFile, "{}"); + + const adapter = createExecutorAdapter("reasonix"); + await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: "Finding C1: SQL injection in src/db.ts", + handle: { kind: "reasonix", sessionFilePath: sessionFile }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const promptFile = path.join(tmpDir, "cycle-2-prompt.txt"); + if (fs.existsSync(promptFile)) { + const content = fs.readFileSync(promptFile, "utf8"); + expect(content).toContain("SQL injection"); + expect(content).toContain("DO NOT run git commit"); + } + }); + + it("scope remediation prompt authorizes only listed cleanup paths", async () => { + const fakeBin = writeFakeCli(tmpDir, {}); + const sessionFile = path.join(tmpDir, "session.jsonl"); + fs.writeFileSync(sessionFile, "{}"); + + const adapter = createExecutorAdapter("reasonix"); + await adapter.resume({ + repoRoot: tmpDir, + plan: makeValidPlan(), + acceptedFindings: [ + "# Scope Remediation", + "", + "- scope-1: tmp_oc.db-shm", + "- scope-2: tmp_oc.db-wal", + ].join("\n"), + handle: { kind: "reasonix", sessionFilePath: sessionFile }, + runDir: tmpDir, + cycleIndex: 2, + config: { binary: fakeBin }, + }); + + const content = fs.readFileSync(path.join(tmpDir, "cycle-2-prompt.txt"), "utf8"); + expect(content).toContain("# Scope Fix Request"); + expect(content).toContain("scope-1: tmp_oc.db-shm"); + expect(content).toContain("only the exact out-of-scope paths it lists"); + expect(content).toContain("Do not modify any other out-of-scope path"); + }); + + it("Claude adapter passes --safe-mode and --permission-mode bypassPermissions", async () => { + const fakeBin = path.join(tmpDir, "claude"); + fs.writeFileSync(fakeBin, `#!/bin/bash +echo '{"session_id":"test-session"}' +echo "$@" > ${path.join(tmpDir, "args.txt")} +`, { mode: 0o755 }); + + const adapter = createExecutorAdapter("claude"); + await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + const args = fs.readFileSync(path.join(tmpDir, "args.txt"), "utf8"); + expect(args).toContain("--safe-mode"); + expect(args).toContain("--permission-mode"); + expect(args).toContain("bypassPermissions"); + }); + + it("Claude adapter extracts errors from JSON result", async () => { + const fakeBin = path.join(tmpDir, "claude"); + fs.writeFileSync(fakeBin, `#!/bin/bash +echo '{"session_id":"test-session","errors":["Permission denied","File not found"]}' +exit 1 +`, { mode: 0o755 }); + + const adapter = createExecutorAdapter("claude"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + expect(result.failed).toBe(true); + expect(result.failureReason).toContain("Permission denied"); + expect(result.failureReason).toContain("File not found"); + }); + + it("Claude adapter extracts is_error result messages", async () => { + const fakeBin = path.join(tmpDir, "claude"); + fs.writeFileSync(fakeBin, `#!/bin/bash +echo '{"session_id":"test-session","is_error":true,"result":"Session already active"}' +exit 1 +`, { mode: 0o755 }); + + const adapter = createExecutorAdapter("claude"); + const result = await adapter.start({ + repoRoot: tmpDir, + plan: makeValidPlan(), + runDir: tmpDir, + cycleIndex: 1, + config: { binary: fakeBin }, + }); + + expect(result.failed).toBe(true); + expect(result.failureReason).toBe("Session already active"); + }); + + it("probe failure directs users to agent-workflow.json", async () => { + const { probeExecutor } = await import("./workflow-executor.js"); + + // Use a valid executor kind but with a bad binary path + await expect(probeExecutor("reasonix", { binary: "/nonexistent/path/reasonix" })) + .rejects.toThrow(/agent-workflow\.json/); + }); +}); diff --git a/src/workflow-executor.ts b/src/workflow-executor.ts new file mode 100644 index 0000000..b54cc25 --- /dev/null +++ b/src/workflow-executor.ts @@ -0,0 +1,650 @@ +/** + * Executor adapters for Reasonix, Claude Code, and Agy. + * + * All adapters: + * - Spawn processes with argument arrays (no shell interpolation) + * - Include fixed safety constraints in every prompt (no commit/push/reset) + * - Return ExecutorResult with session handle for future resume + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { spawnStreamingChild } from "./child-process.js"; +import type { + AgySessionHandle, + ClaudeSessionHandle, + ExecutorAdapter, + ExecutorConfig, + ExecutorKind, + ExecutorResumeOpts, + ExecutorResult, + ExecutorSessionHandle, + ExecutorStartOpts, + ReasonixSessionHandle, +} from "./workflow-types.js"; +import { nowIso } from "./workflow-state.js"; + +// --------------------------------------------------------------------------- +// Safety prompt footer (appended to every executor instruction) +// --------------------------------------------------------------------------- + +const SAFETY_CONSTRAINTS = ` +## CRITICAL CONSTRAINTS — DO NOT VIOLATE + +- DO NOT run git commit, git push, or git reset under any circumstances. +- 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. +- 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(); + +// --------------------------------------------------------------------------- +// Prompt builders +// --------------------------------------------------------------------------- + +function buildStartPrompt(opts: ExecutorStartOpts): string { + const { plan } = opts; + const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n"); + const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n"); + const cmdsText = plan.verificationCommands + .map((cmd) => ` ${cmd.join(" ")}`) + .join("\n"); + + return `# Implementation Task: ${plan.title} + +## Plan + +${plan.planMarkdown} + +## Allowed Scope + +Only modify files within these paths (relative to repository root): +${scopeList} + +## Acceptance Criteria + +${criteriaList} + +## Verification Commands + +After implementation, ensure these commands pass: +${cmdsText} + +${SAFETY_CONSTRAINTS}`; +} + +function buildFixPrompt(opts: ExecutorResumeOpts): string { + const { plan, acceptedFindings } = opts; + + if (acceptedFindings.startsWith("# Scope Remediation")) { + return `# Scope Fix Request: ${plan.title} + +${acceptedFindings} + +Do not modify any other out-of-scope path. If a listed path is not clearly an executor-created artifact, stop and report it instead of deleting it. + +## Frozen Scope + +The implementation itself must remain within: +${plan.scope.map((s) => ` - ${s}`).join("\n")} + +## Verification + +After scope cleanup, run: +${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} + +${SAFETY_CONSTRAINTS}`; + } + + return `# Fix Request: ${plan.title} + +The code review found the following issues that must be fixed: + +${acceptedFindings} + +## Scope Reminder + +Only modify files within: +${plan.scope.map((s) => ` - ${s}`).join("\n")} + +## Verification + +After applying fixes, run: +${plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n")} + +${SAFETY_CONSTRAINTS}`; +} + +// --------------------------------------------------------------------------- +// Child env (inherit + extra PATH entries for executors) +// --------------------------------------------------------------------------- + +function executorEnv(): NodeJS.ProcessEnv { + const homeDir = process.env.HOME || os.homedir(); + const extras = [ + path.join(homeDir, ".local", "bin"), + path.join(homeDir, ".bun", "bin"), + path.join(homeDir, ".n", "bin"), + "/usr/local/bin", + "/usr/bin", + "/bin", + process.env.PATH || "", + ]; + return { + ...process.env, + PATH: [...new Set(extras.join(":").split(":").filter(Boolean))].join(":"), + }; +} + +function resolveExecutable(config: ExecutorConfig, defaultBin: string): string { + return config.binary || defaultBin; +} + +// --------------------------------------------------------------------------- +// Reasonix adapter +// --------------------------------------------------------------------------- + +function readFirstLine(filePath: string): string | undefined { + const CHUNK_SIZE = 65536; + const fd = fs.openSync(filePath, "r"); + let buffer = Buffer.alloc(CHUNK_SIZE); + let totalData = ""; + let newlineIndex = -1; + let position = 0; + try { + while (true) { + const bytesRead = fs.readSync(fd, buffer, 0, CHUNK_SIZE, position); + if (bytesRead === 0) break; + totalData += buffer.toString("utf8", 0, bytesRead); + newlineIndex = totalData.indexOf("\n"); + if (newlineIndex !== -1) { + return totalData.slice(0, newlineIndex); + } + position += bytesRead; + if (position > 1024 * 1024) break; // 1MB safety limit + } + return totalData; + } finally { + fs.closeSync(fd); + } +} + +function getWorkspaceFromJsonl(jsonlPath: string): string | undefined { + try { + const firstLine = readFirstLine(jsonlPath); + if (!firstLine) return undefined; + const obj = JSON.parse(firstLine); + if (obj && obj.role === "system" && typeof obj.content === "string") { + let match = obj.content.match(/Current workspace:\s*"([^"\n\r]+)"/i); + if (!match) { + match = obj.content.match(/Current workspace:\s*([^"\n\r]+)/i); + } + if (match) { + return match[1].trim(); + } + } + } catch { + // ignore + } + return undefined; +} + +function listJsonlFiles(dir: string, repoRoot?: string): string[] { + if (!fs.existsSync(dir)) return []; + const result: string[] = []; + + function recurse(current: string) { + if (!fs.existsSync(current)) return; + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + const lowerDir = entry.name.toLowerCase(); + if (lowerDir !== "archive" && lowerDir !== "subagents") { + recurse(full); + } + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + const lowerName = entry.name.toLowerCase(); + if ( + !lowerName.includes("events") && + !lowerName.includes("archive") && + !lowerName.includes("conflict") && + !lowerName.includes("subagents") + ) { + // Check companion file name: .jsonl.meta + const metaFile = full + ".meta"; + if (fs.existsSync(metaFile)) { + if (repoRoot) { + const ws = getWorkspaceFromJsonl(full); + if (ws === repoRoot) { + result.push(full); + } + } else { + result.push(full); + } + } + } + } + } + } + + recurse(dir); + return result; +} + +function detectNewSessionFile(beforeSnapshot: Set, searchDirs: string[], repoRoot: string): string | undefined { + const candidates: string[] = []; + for (const dir of searchDirs) { + for (const file of listJsonlFiles(dir, repoRoot)) { + if (!beforeSnapshot.has(file)) { + candidates.push(file); + } + } + } + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + candidates.sort((a, b) => { + try { + return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; + } catch { + return 0; + } + }); + return candidates[0]; +} + +class ReasonixAdapter implements ExecutorAdapter { + readonly kind: ExecutorKind = "reasonix"; + + async probe(config: ExecutorConfig = {}): Promise { + try { + const res = spawnSync(resolveExecutable(config, "reasonix"), ["--version"], { + stdio: "ignore", + env: executorEnv(), + }); + return res.status === 0 && !res.error; + } catch { + return false; + } + } + + async start(opts: ExecutorStartOpts): Promise { + const bin = resolveExecutable(opts.config, "reasonix"); + const prompt = buildStartPrompt(opts); + const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`); + fs.writeFileSync(promptFile, prompt, "utf8"); + + // Snapshot JSONL files before run to detect the session file created by reasonix + const homeDir = process.env.HOME || os.homedir(); + const projectsDir = path.join(homeDir, ".reasonix", "projects"); + fs.mkdirSync(projectsDir, { recursive: true }); + const snapshotDirs = [projectsDir]; + const beforeSnapshot = new Set(listJsonlFiles(projectsDir, opts.repoRoot)); + + const args: string[] = ["run", "--dir", opts.repoRoot, prompt]; + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const newSession = detectNewSessionFile(beforeSnapshot, snapshotDirs, opts.repoRoot); + const handle: ReasonixSessionHandle | undefined = newSession + ? { kind: "reasonix", sessionFilePath: newSession } + : undefined; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + const failed = child.status !== 0 || !!child.error; + + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, + failed, + ...(child.error ? { failureReason: child.error.message } : {}), + }; + } + + async resume(opts: ExecutorResumeOpts): Promise { + const handle = opts.handle as ReasonixSessionHandle; + const bin = resolveExecutable(opts.config, "reasonix"); + const prompt = buildFixPrompt(opts); + const promptFile = path.join(opts.runDir, `cycle-${opts.cycleIndex}-prompt.txt`); + fs.writeFileSync(promptFile, prompt, "utf8"); + + const args: string[] = [ + "run", + "--dir", opts.repoRoot, + "--resume", handle.sessionFilePath, + prompt, + ]; + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, // Reasonix handle stays the same file + failed: child.status !== 0 || !!child.error, + ...(child.error ? { failureReason: child.error.message } : {}), + }; + } + + async cancel(_handle: ExecutorSessionHandle): Promise { + // Reasonix does not expose a cancel API in v1; no-op. + } +} + +// --------------------------------------------------------------------------- +// Claude Code adapter +// --------------------------------------------------------------------------- + +function extractClaudeSessionId(output: string): string | undefined { + // Claude Code outputs JSON with session_id field when using --output-format json + try { + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + const parsed = JSON.parse(trimmed) as Record; + if (typeof parsed.session_id === "string") return parsed.session_id; + } + } catch { + // fall through + } + // Also try a simple regex + const m = output.match(/"session_id"\s*:\s*"([^"]+)"/); + return m?.[1]; +} + +function extractClaudeFailureReason(output: string): string | undefined { + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const parsed = JSON.parse(trimmed) as Record; + if (Array.isArray(parsed.errors)) { + const errors = parsed.errors.filter((error): error is string => typeof error === "string" && error.trim().length > 0); + if (errors.length > 0) return errors.join("; "); + } + if (parsed.is_error === true && typeof parsed.result === "string" && parsed.result.trim()) { + return parsed.result.trim(); + } + } catch { + // Ignore non-JSON output lines. + } + } + return undefined; +} + +import { randomUUID } from "node:crypto"; + +class ClaudeAdapter implements ExecutorAdapter { + readonly kind: ExecutorKind = "claude"; + + async probe(config: ExecutorConfig = {}): Promise { + try { + const res = spawnSync(resolveExecutable(config, "claude"), ["--version"], { + stdio: "ignore", + env: executorEnv(), + }); + return res.status === 0 && !res.error; + } catch { + return false; + } + } + + async start(opts: ExecutorStartOpts): Promise { + const bin = resolveExecutable(opts.config, "claude"); + const sessionId = randomUUID(); + const prompt = buildStartPrompt(opts); + + const args: string[] = [ + "-p", + "--safe-mode", + "--permission-mode", "bypassPermissions", + "--output-format", "json", + "--session-id", sessionId, + ...(opts.config.model ? ["--model", opts.config.model] : []), + prompt, + ]; + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const capturedId = extractClaudeSessionId(child.stdout) ?? sessionId; + const handle: ClaudeSessionHandle = { kind: "claude", sessionId: capturedId }; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + const failureReason = child.error?.message + ?? extractClaudeFailureReason(child.stdout) + ?? extractClaudeFailureReason(child.stderr); + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, + failed: child.status !== 0 || !!child.error, + ...(failureReason ? { failureReason } : {}), + }; + } + + async resume(opts: ExecutorResumeOpts): Promise { + const handle = opts.handle as ClaudeSessionHandle; + const bin = resolveExecutable(opts.config, "claude"); + const prompt = buildFixPrompt(opts); + + const args: string[] = [ + "-p", + "--safe-mode", + "--permission-mode", "bypassPermissions", + "--output-format", "json", + "--resume", handle.sessionId, + ...(opts.config.model ? ["--model", opts.config.model] : []), + prompt, + ]; + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + const failureReason = child.error?.message + ?? extractClaudeFailureReason(child.stdout) + ?? extractClaudeFailureReason(child.stderr); + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, + failed: child.status !== 0 || !!child.error, + ...(failureReason ? { failureReason } : {}), + }; + } + + async cancel(_handle: ExecutorSessionHandle): Promise { + // Claude Code: no explicit cancel API in v1. + } +} + +// --------------------------------------------------------------------------- +// Agy adapter +// --------------------------------------------------------------------------- + +function extractAgyConversationId(output: string): string | undefined { + // Try to parse conversation_id from JSON output lines + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const parsed = JSON.parse(trimmed) as Record; + if (typeof parsed.conversation_id === "string") return parsed.conversation_id; + if (typeof parsed.conversationId === "string") return parsed.conversationId; + } catch { /* skip */ } + } + return undefined; +} + +class AgyAdapter implements ExecutorAdapter { + readonly kind: ExecutorKind = "agy"; + + async probe(config: ExecutorConfig = {}): Promise { + try { + const res = spawnSync(resolveExecutable(config, "agy"), ["--version"], { + stdio: "ignore", + env: executorEnv(), + }); + return res.status === 0 && !res.error; + } catch { + return false; + } + } + + async start(opts: ExecutorStartOpts): Promise { + const bin = resolveExecutable(opts.config, "agy"); + const prompt = buildStartPrompt(opts); + + const args: string[] = [ + ...(opts.config.agent ? ["--agent", opts.config.agent] : []), + ...(opts.config.model ? ["--model", opts.config.model] : []), + "--print", "--mode", "accept-edits", "--", prompt, + ]; + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const conversationId = extractAgyConversationId(child.stdout); + const handle: AgySessionHandle = { + kind: "agy", + ...(conversationId ? { conversationId } : {}), + degraded: !conversationId, + }; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, + failed: child.status !== 0 || !!child.error, + ...(child.error ? { failureReason: child.error.message } : {}), + }; + } + + async resume(opts: ExecutorResumeOpts): Promise { + const handle = opts.handle as AgySessionHandle; + const bin = resolveExecutable(opts.config, "agy"); + const prompt = buildFixPrompt(opts); + + let args: string[]; + if (handle.conversationId) { + args = [ + "--conversation", handle.conversationId, + ...(opts.config.agent ? ["--agent", opts.config.agent] : []), + ...(opts.config.model ? ["--model", opts.config.model] : []), + "--print", "--mode", "accept-edits", "--", prompt, + ]; + } else { + // Degraded mode: use --continue (best effort, may pick wrong session in concurrent setups) + args = [ + "--continue", + ...(opts.config.agent ? ["--agent", opts.config.agent] : []), + ...(opts.config.model ? ["--model", opts.config.model] : []), + "--print", "--mode", "accept-edits", "--", prompt, + ]; + } + + const startMs = Date.now(); + const child = await spawnStreamingChild(bin, args, { + cwd: opts.repoRoot, + env: executorEnv(), + signal: opts.signal, + processGroup: true, + }); + const durationMs = Date.now() - startMs; + + const report = child.stdout + (child.stderr ? `\n[stderr]\n${child.stderr}` : ""); + return { + exitCode: child.status, + report, + durationMs, + sessionHandle: handle, + failed: child.status !== 0 || !!child.error, + ...(child.error ? { failureReason: child.error.message } : {}), + }; + } + + async cancel(_handle: ExecutorSessionHandle): Promise { + // Agy: no explicit cancel API in v1. + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createExecutorAdapter(kind: ExecutorKind): ExecutorAdapter { + switch (kind) { + case "reasonix": + return new ReasonixAdapter(); + case "claude": + return new ClaudeAdapter(); + case "agy": + return new AgyAdapter(); + default: { + const _exhaustive: never = kind; + throw new Error(`Unknown executor kind: ${_exhaustive}`); + } + } +} + +/** Check that the executor binary is available and throw a descriptive error if not. */ +export async function probeExecutor(kind: ExecutorKind, config: ExecutorConfig = {}): Promise { + const adapter = createExecutorAdapter(kind); + const available = await adapter.probe(config); + if (!available) { + const bin = config.binary || kind; + throw new Error( + `Executor '${kind}' is not available. ` + + `Make sure '${bin}' is installed and on PATH. ` + + `You can override the binary path with the 'binary' field in agent-workflow.json.`, + ); + } +} diff --git a/src/workflow-integration.test.ts b/src/workflow-integration.test.ts new file mode 100644 index 0000000..28e1649 --- /dev/null +++ b/src/workflow-integration.test.ts @@ -0,0 +1,273 @@ +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 } from "node:child_process"; +import { startWorkflow, reviewWorkflow, decideWorkflow, retryExecuteWorkflow } from "./workflow-engine.js"; +import { readState, runDir } from "./workflow-state.js"; +import type { HostDecisionV1 } from "./workflow-types.js"; + +// Helper to create a fake CLI script +function writeFakeCli(dir: string, name: string, scriptBody: string): string { + const p = path.join(dir, name); + fs.writeFileSync(p, `#!/bin/bash\n${scriptBody}`, { mode: 0o755 }); + return p; +} + +describe("Workflow Integration (start -> review -> fix -> review -> accept)", () => { + let tmpDir: string; + let baseTmp: string; + let homeDir: string; + let workflowDir: string; + let originalHome: string | undefined; + let originalExit: typeof process.exit; + let originalWorkflowDir: string | undefined; + + beforeEach(() => { + originalExit = process.exit; + (process as any).exit = (code?: number) => { + if (code === 0) throw new Error("MockExit0"); + originalExit(code); + }; + + delete process.env.GIT_DIR; + delete process.env.GIT_WORK_TREE; + baseTmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-integration-"))); + tmpDir = path.join(baseTmp, "John's 项目 空格+中文"); + fs.mkdirSync(tmpDir); + homeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-home-"))); + workflowDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-workflows-"))); + + originalHome = process.env.HOME; + originalWorkflowDir = process.env.AGENT_WORKFLOW_DIR; + + process.env.HOME = homeDir; + process.env.AGENT_WORKFLOW_DIR = workflowDir; + + // Init real git repo for baselineHead + execFileSync("git", ["init"], { cwd: tmpDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: tmpDir }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: tmpDir }); + fs.writeFileSync(path.join(tmpDir, "index.ts"), "const x = 1;\n"); + execFileSync("git", ["add", "index.ts"], { cwd: tmpDir }); + execFileSync("git", ["commit", "-m", "init"], { cwd: tmpDir }); + }); + + afterEach(() => { + fs.rmSync(baseTmp, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(workflowDir, { recursive: true, force: true }); + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + if (originalWorkflowDir !== undefined) { + process.env.AGENT_WORKFLOW_DIR = originalWorkflowDir; + } else { + delete process.env.AGENT_WORKFLOW_DIR; + } + + process.exit = originalExit; + }); + + it("completes a full loop with reasonix", async () => { + // 1. Fake Executor CLI (Reasonix) + const executorScript = ` +if [[ "$*" == *"--version"* ]]; then + echo "v1.0.0" + exit 0 +fi + +if [[ "$*" == *"--resume"* ]]; then + if [[ "$*" == *"# Scope Remediation"* ]]; then + if [[ ! -f "$HOME/scope-retry-ready" ]]; then + : > "$HOME/scope-retry-ready" + echo "session busy" >&2 + exit 1 + fi + # Cycle 2: remove only the listed SQLite sidecars. + rm -f "$3/tmp_oc.db-shm" "$3/tmp_oc.db-wal" + echo "Scope cleaned!" + else + # Cycle 3: fix the Codex finding. + echo "const x = 3;" > "$3/index.ts" + echo "Fixed!" + fi + exit 0 +else + # Cycle 1: implement with a bug and accidentally leave SQLite sidecars. + echo "const x = 2; // bug" > "$3/index.ts" + : > "$3/tmp_oc.db-shm" + : > "$3/tmp_oc.db-wal" + echo "Done!" + # Create fake session file for reasonix + mkdir -p "$HOME/.reasonix/projects/mock-project/sessions" + echo '{"role": "system", "content": "Current workspace: \\"'"$3"'\\""}' > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl" + echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/session_1.jsonl.meta" + + # Create a newer subagent session to verify it gets correctly ignored + mkdir -p "$HOME/.reasonix/projects/mock-project/sessions/subagents" + sleep 1 + echo '{"role": "system", "content": "Current workspace: '"$3"'"}' > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl" + echo "{}" > "$HOME/.reasonix/projects/mock-project/sessions/subagents/sub_session.jsonl.meta" +fi +`; + const executorBin = writeFakeCli(tmpDir, "fake-executor.sh", executorScript); + + fs.writeFileSync(path.join(tmpDir, "agent-workflow.json"), JSON.stringify({ + version: "1", + maxCycles: 3, + executors: { + reasonix: { binary: executorBin } + } + })); + + const plan = { + version: "1", + title: "Add a file", + planMarkdown: "Add an index.ts file.", + scope: ["index.ts"], + acceptanceCriteria: ["x is 3"], + verificationCommands: [["grep", "const x = 3;", "index.ts"]], + } as const; + + const planPath = path.join(tmpDir, "plan.json"); + fs.writeFileSync(planPath, JSON.stringify(plan)); + + execFileSync("git", ["add", "."], { cwd: tmpDir }); + execFileSync("git", ["commit", "-m", "config"], { cwd: tmpDir }); + + // --- STEP 1: START --- + const { runId } = await startWorkflow({ + cwd: tmpDir, + executor: "reasonix", + planInput: planPath, + }); + + const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: tmpDir, encoding: "utf8" }).trim(); + let state = readState(runDir(repoRoot, runId))!; + expect(state.status).toBe("awaiting_review"); + expect(state.currentCycle).toBe(1); + + // --- STEP 2: SCOPE GATE (Cycle 1, Codex review bundle is not ready yet) --- + await reviewWorkflow({ runId, cwd: tmpDir }); + let state2 = readState(runDir(repoRoot, runId))!; + expect(state2.status).toBe("awaiting_scope_resolution"); + expect(state2.cycles[0].scopeViolations).toEqual([ + { id: "scope-1", path: "tmp_oc.db-shm", kind: "out_of_scope" }, + { id: "scope-2", path: "tmp_oc.db-wal", kind: "out_of_scope" }, + ]); + expect(state2.cycles[0].hostReviewFile).toBeUndefined(); + + const incompleteDecision: HostDecisionV1 = { + version: "1", + outcome: "fix", + findingDecisions: [{ findingId: "scope-1", disposition: "accept" }], + decidedAt: new Date().toISOString(), + }; + const incompleteDecisionPath = path.join(process.env.HOME!, "decision-incomplete.json"); + fs.writeFileSync(incompleteDecisionPath, JSON.stringify(incompleteDecision)); + await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteDecisionPath })) + .rejects.toThrow(/accept every current violation exactly once/); + expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_scope_resolution"); + + // --- STEP 3: DECIDE (Scope fix using the same Reasonix session) --- + const scopeDecision: HostDecisionV1 = { + version: "1", + outcome: "fix", + findingDecisions: [ + { findingId: "scope-1", disposition: "accept" }, + { findingId: "scope-2", disposition: "accept" }, + ], + decidedAt: new Date().toISOString() + }; + const scopeDecisionPath = path.join(process.env.HOME!, "decision-scope.json"); + fs.writeFileSync(scopeDecisionPath, JSON.stringify(scopeDecision)); + await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: scopeDecisionPath })) + .rejects.toThrow(/Executor failed/); + expect(readState(runDir(repoRoot, runId))?.status).toBe("blocked"); + + await retryExecuteWorkflow({ runId }); + + let state3 = readState(runDir(repoRoot, runId))!; + expect(state3.status).toBe("awaiting_review"); + expect(state3.currentCycle).toBe(2); + expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-shm"))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, "tmp_oc.db-wal"))).toBe(false); + + // --- STEP 4: PREPARE CODEX REVIEW (Cycle 2) --- + await reviewWorkflow({ runId, cwd: tmpDir }); + let state4 = readState(runDir(repoRoot, runId))!; + expect(state4.status).toBe("awaiting_host"); + expect(state4.cycles[1].hostReviewFile).toBe("cycle-2-host-review.json"); + const reviewBundle = JSON.parse(fs.readFileSync( + path.join(runDir(repoRoot, runId), state4.cycles[1].hostReviewFile!), + "utf8", + )); + expect(reviewBundle.repoRoot).toBe(repoRoot); + expect(reviewBundle.diffFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2.diff")); + expect(reviewBundle.executorLogFile).toBe(path.join(runDir(repoRoot, runId), "cycle-2-exec-retry-1.log")); + expect(state4.cycles[1].executorAttemptLogs).toEqual([ + "cycle-2-exec.log", + "cycle-2-exec-retry-1.log", + ]); + + // --- STEP 5: CODEX DECIDES FIX --- + const incompleteFix: HostDecisionV1 = { + version: "1", + outcome: "fix", + findingDecisions: [{ findingId: "codex-1", disposition: "accept" }], + decidedAt: new Date().toISOString(), + }; + const incompleteFixPath = path.join(process.env.HOME!, "decision-fix-incomplete.json"); + fs.writeFileSync(incompleteFixPath, JSON.stringify(incompleteFix)); + await expect(decideWorkflow({ runId, cwd: tmpDir, decisionInput: incompleteFixPath })) + .rejects.toThrow(/require a non-empty summary/); + expect(readState(runDir(repoRoot, runId))?.status).toBe("awaiting_host"); + + const fixDecision: HostDecisionV1 = { + version: "1", + outcome: "fix", + findingDecisions: [{ + findingId: "codex-1", + disposition: "accept", + summary: "index.ts sets x to 2 instead of the required value 3", + path: "index.ts", + }], + decidedAt: new Date().toISOString() + }; + const fixDecisionPath = path.join(process.env.HOME!, "decision-fix.json"); + fs.writeFileSync(fixDecisionPath, JSON.stringify(fixDecision)); + await decideWorkflow({ runId, cwd: tmpDir, decisionInput: fixDecisionPath }); + + let state5 = readState(runDir(repoRoot, runId))!; + expect(state5.status).toBe("awaiting_review"); + expect(state5.currentCycle).toBe(3); + + // --- STEP 6: PREPARE CODEX RE-REVIEW (Cycle 3) --- + await reviewWorkflow({ runId, cwd: tmpDir }); + let state6 = readState(runDir(repoRoot, runId))!; + expect(state6.status).toBe("awaiting_host"); + expect(state6.cycles[2].hostReviewFile).toBe("cycle-3-host-review.json"); + + // --- STEP 7: DECIDE (Accept) --- + const acceptDecision: HostDecisionV1 = { + version: "1", + outcome: "accept", + findingDecisions: [], + verificationEvidence: ["verified output"], + decidedAt: new Date().toISOString() + }; + const acceptDecisionPath = path.join(process.env.HOME!, "decision2.json"); + fs.writeFileSync(acceptDecisionPath, JSON.stringify(acceptDecision)); + try { + await decideWorkflow({ runId, cwd: tmpDir, decisionInput: acceptDecisionPath }); + } catch (err: any) { + if (err.message !== "MockExit0") throw err; + } + + let state7 = readState(runDir(repoRoot, runId))!; + expect(state7.status).toBe("completed"); + }); +}); diff --git a/src/workflow-state.test.ts b/src/workflow-state.test.ts new file mode 100644 index 0000000..d10aa7a --- /dev/null +++ b/src/workflow-state.test.ts @@ -0,0 +1,264 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadProjectWorkflowConfig } from "./workflow-config.js"; +import { + WorkflowLockError, + acquireLock, + findRunDir, + lockFilePath, + readLock, + releaseLock, + repoHash, + runDir, + workflowsRoot, +} from "./workflow-state.js"; + +describe("Workflow state root and locks", () => { + let tmpDir: string; + let repoRoot: string; + let stateRoot: string; + let originalAgentDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-state-test-")); + repoRoot = path.join(tmpDir, "repo"); + stateRoot = path.join(tmpDir, "workflows"); + fs.mkdirSync(repoRoot, { recursive: true }); + originalAgentDir = process.env.AGENT_WORKFLOW_DIR; + process.env.AGENT_WORKFLOW_DIR = stateRoot; + }); + + afterEach(() => { + if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; + else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("uses AGENT_WORKFLOW_DIR for all state", () => { + expect(workflowsRoot()).toBe(stateRoot); + expect(runDir(repoRoot, "run-1")).toBe(path.join(stateRoot, repoHash(repoRoot), "run-1")); + expect(lockFilePath(repoRoot)).toBe(path.join(stateRoot, repoHash(repoRoot), "active.lock")); + }); + + it("finds a run with and without a repository hint", () => { + const expected = runDir(repoRoot, "run-1"); + fs.mkdirSync(expected, { recursive: true }); + fs.writeFileSync(path.join(expected, "state.json"), "{}\n"); + + expect(findRunDir("run-1", repoRoot)).toBe(expected); + expect(findRunDir("run-1")).toBe(expected); + expect(findRunDir("missing", repoRoot)).toBeUndefined(); + }); + + it("reuses the same run lock and rejects a different run", () => { + acquireLock(repoRoot, "run-1"); + expect(readLock(repoRoot)).toBe("run-1"); + expect(() => acquireLock(repoRoot, "run-1")).not.toThrow(); + expect(() => acquireLock(repoRoot, "run-2")).toThrow(WorkflowLockError); + }); + + it("releases only the owning run lock", () => { + acquireLock(repoRoot, "run-1"); + releaseLock(repoRoot, "run-2"); + expect(readLock(repoRoot)).toBe("run-1"); + releaseLock(repoRoot, "run-1"); + expect(readLock(repoRoot)).toBeUndefined(); + }); +}); + +describe("Project workflow config", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-config-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("loads agent-workflow.json", () => { + fs.writeFileSync( + path.join(tmpDir, "agent-workflow.json"), + JSON.stringify({ version: "1", defaultExecutor: "claude", maxCycles: 10 }), + ); + + expect(loadProjectWorkflowConfig(tmpDir)).toMatchObject({ + defaultExecutor: "claude", + maxCycles: 10, + }); + }); + + it("returns undefined when no config exists", () => { + expect(loadProjectWorkflowConfig(tmpDir)).toBeUndefined(); + }); +}); + +describe("Retry-execute recovery", () => { + let tmpDir: string; + let repoRoot: string; + let stateRoot: string; + let originalAgentDir: string | undefined; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-retry-test-")); + repoRoot = path.join(tmpDir, "repo"); + stateRoot = path.join(tmpDir, "workflows"); + fs.mkdirSync(repoRoot, { 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 User"], { cwd: repoRoot }); + fs.writeFileSync(path.join(repoRoot, "test.txt"), "test"); + execFileSync("git", ["add", "."], { cwd: repoRoot }); + execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot }); + originalAgentDir = process.env.AGENT_WORKFLOW_DIR; + process.env.AGENT_WORKFLOW_DIR = stateRoot; + }); + + afterEach(() => { + if (originalAgentDir === undefined) delete process.env.AGENT_WORKFLOW_DIR; + else process.env.AGENT_WORKFLOW_DIR = originalAgentDir; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFakeExecutor(sessionFilePath: string): string { + const scriptPath = path.join(path.dirname(sessionFilePath), "fake-reasonix"); + const script = `#!/bin/bash +set -e +SESSION_FILE="${sessionFilePath}" +echo '{"type":"session_started"}' > "\${SESSION_FILE}" +echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}' +`; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + return scriptPath; + } + + function plan() { + return { + version: "1" as const, + title: "Test", + planMarkdown: "Test plan", + scope: ["test.txt"], + acceptanceCriteria: ["Recovery succeeds"], + verificationCommands: [["git", "status", "--short"]], + }; + } + + it("recovers a stale executing state without changing its cycle or session", async () => { + const { retryExecuteWorkflow } = await import("./workflow-engine.js"); + const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); + const runId = "stale-run-123"; + const dir = runDir(repoRoot, runId); + fs.mkdirSync(dir, { recursive: true }); + const sessionFile = path.join(dir, "session.jsonl"); + fs.writeFileSync(sessionFile, '{"type":"session"}'); + const state = initWorkflowState({ + runId, + repoRoot, + baselineHead: gitHead(repoRoot), + plan: plan(), + executor: "reasonix", + maxCycles: 3, + }); + state.status = "executing"; + state.enginePid = 99_999_999; + state.currentCycle = 1; + state.cycles = [{ + cycleIndex: 1, + startedAt: new Date().toISOString(), + executorLogFile: "cycle-1-exec.log", + executorAttemptLogs: ["cycle-1-exec.log"], + }]; + state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile }; + state.executorConfig = { binary: writeFakeExecutor(sessionFile) }; + fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed"); + acquireLock(repoRoot, runId); + writeState(dir, state); + + await retryExecuteWorkflow({ runId }); + + const recovered = readState(dir)!; + const cycle = recovered.cycles[0]!; + expect(recovered.status).toBe("awaiting_review"); + expect(recovered.currentCycle).toBe(1); + expect(recovered.sessionHandle).toEqual(state.sessionHandle); + expect(cycle.executorAttemptLogs).toHaveLength(2); + expect(cycle.executorAttemptLogs?.[0]).toBe("cycle-1-exec.log"); + expect(cycle.executorLogFile).toBe(cycle.executorAttemptLogs?.[1]); + const retryLog = fs.readFileSync(path.join(dir, cycle.executorLogFile!), "utf8"); + expect(retryLog).not.toMatch(/Permission denied|command not found|No such file/); + const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8") + .trim().split("\n").map((line) => JSON.parse(line)); + expect(events.find((event) => event.kind === "executor_retried")?.data.recoveryMode) + .toBe("stale_execution"); + }); + + it("rejects retry while the recorded engine process is alive", async () => { + const { retryExecuteWorkflow } = await import("./workflow-engine.js"); + const { gitHead, initWorkflowState, writeState } = await import("./workflow-state.js"); + const runId = "live-run-456"; + const dir = runDir(repoRoot, runId); + const state = initWorkflowState({ + runId, + repoRoot, + baselineHead: gitHead(repoRoot), + plan: plan(), + executor: "reasonix", + maxCycles: 3, + }); + state.status = "executing"; + state.enginePid = process.pid; + state.cycles = [{ cycleIndex: 1, startedAt: new Date().toISOString() }]; + writeState(dir, state); + + await expect(retryExecuteWorkflow({ runId })) + .rejects.toThrow(/cannot retry execution from status 'executing'/); + }); + + it("recovers a failed initial cycle and preserves attempt logs", async () => { + const { retryExecuteWorkflow } = await import("./workflow-engine.js"); + const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js"); + const runId = "initial-retry-789"; + const dir = runDir(repoRoot, runId); + fs.mkdirSync(dir, { recursive: true }); + const sessionFile = path.join(dir, "session.jsonl"); + fs.writeFileSync(sessionFile, '{"type":"session"}'); + const state = initWorkflowState({ + runId, + repoRoot, + baselineHead: gitHead(repoRoot), + plan: plan(), + executor: "reasonix", + maxCycles: 3, + }); + state.status = "blocked"; + state.stopDescription = "Executor exited with failure: test error"; + state.cycles = [{ + cycleIndex: 1, + startedAt: new Date().toISOString(), + executorLogFile: "cycle-1-exec.log", + executorAttemptLogs: ["cycle-1-exec.log"], + }]; + state.sessionHandle = { kind: "reasonix", sessionFilePath: sessionFile }; + state.executorConfig = { binary: writeFakeExecutor(sessionFile) }; + fs.writeFileSync(path.join(dir, "cycle-1-exec.log"), "initial attempt failed"); + writeState(dir, state); + + await retryExecuteWorkflow({ runId }); + + const recovered = readState(dir)!; + const cycle = recovered.cycles[0]!; + expect(recovered.status).toBe("awaiting_review"); + expect(recovered.currentCycle).toBe(1); + expect(recovered.sessionHandle).toEqual(state.sessionHandle); + expect(cycle.executorAttemptLogs).toHaveLength(2); + expect(cycle.executorLogFile).toBe(cycle.executorAttemptLogs?.[1]); + const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8") + .trim().split("\n").map((line) => JSON.parse(line)); + expect(events.find((event) => event.kind === "executor_retried")?.data.recoveryMode) + .toBe("initial_continuation"); + }); +}); diff --git a/src/workflow-state.ts b/src/workflow-state.ts new file mode 100644 index 0000000..64e4839 --- /dev/null +++ b/src/workflow-state.ts @@ -0,0 +1,342 @@ +/** + * Workflow state persistence. + * + * State files live at: + * ~/.agent-workflow/workflows/// + * state.json — current machine state (atomic write via temp file) + * events.jsonl — append-only event log + * cycle-.diff — diff relative to baseline after cycle N + * cycle--exec.log — executor stdout/stderr log + * cycle--host-review.json — evidence bundle for Codex review + * cycle--decision.json — Codex HostDecisionV1 + * + * Only one active workflow per working-tree (keyed by repo-hash + lock file). + */ + +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 { WorkflowEvent, WorkflowState } from "./workflow-types.js"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const DEFAULT_WORKFLOWS_ROOT = path.join(os.homedir(), ".agent-workflow", "workflows"); + +/** Root directory for workflow state. */ +export function workflowsRoot(): string { + return process.env.AGENT_WORKFLOW_DIR || DEFAULT_WORKFLOWS_ROOT; +} + +/** Compute a stable 8-char hex hash for a repo root path. */ +export function repoHash(repoRoot: string): string { + return crypto.createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 8); +} + +/** Run directory for a specific run. */ +export function runDir(repoRoot: string, runId: string): string { + return path.join(workflowsRoot(), repoHash(repoRoot), runId); +} + +/** Lock file path for a repo (one active workflow at a time). */ +export function lockFilePath(repoRoot: string): string { + return path.join(workflowsRoot(), repoHash(repoRoot), "active.lock"); +} + +export function stateFilePath(dir: string): string { + return path.join(dir, "state.json"); +} + +export function eventsFilePath(dir: string): string { + return path.join(dir, "events.jsonl"); +} + +export function cycleDiffFile(dir: string, cycleIndex: number): string { + return path.join(dir, `cycle-${cycleIndex}.diff`); +} + +export function cycleExecLogFile(dir: string, cycleIndex: number): string { + return path.join(dir, `cycle-${cycleIndex}-exec.log`); +} + +export function cycleHostReviewFile(dir: string, cycleIndex: number): string { + return path.join(dir, `cycle-${cycleIndex}-host-review.json`); +} + +export function cycleDecisionFile(dir: string, cycleIndex: number): string { + return path.join(dir, `cycle-${cycleIndex}-decision.json`); +} + +// --------------------------------------------------------------------------- +// Run ID +// --------------------------------------------------------------------------- + +let _runIdSeq = 0; + +export function generateRunId(): string { + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const seq = String(++_runIdSeq).padStart(2, "0"); + const rand = crypto.randomBytes(3).toString("hex"); + return `${ts}_${seq}_${rand}`; +} + +// --------------------------------------------------------------------------- +// Atomic state write +// --------------------------------------------------------------------------- + +/** Write state.json atomically via a temp file + rename. */ +export function writeState(dir: string, state: WorkflowState): void { + const filePath = stateFilePath(dir); + const tmp = `${filePath}.tmp.${process.pid}`; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8"); + fs.renameSync(tmp, filePath); +} + +/** Read and parse state.json from a run dir. Returns undefined if not found. */ +export function readState(dir: string): WorkflowState | undefined { + const filePath = stateFilePath(dir); + if (!fs.existsSync(filePath)) return undefined; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as WorkflowState; + } catch { + return undefined; + } +} + +/** Append a single event to events.jsonl. */ +export function appendEvent(dir: string, event: WorkflowEvent): void { + const filePath = eventsFilePath(dir); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(filePath, JSON.stringify(event) + "\n", "utf8"); +} + +// --------------------------------------------------------------------------- +// Lock management +// --------------------------------------------------------------------------- + +export class WorkflowLockError extends Error { + constructor( + message: string, + readonly existingRunId?: string, + ) { + super(message); + this.name = "WorkflowLockError"; + } +} + +/** Acquire the repo-level lock. Reusing the same run's lock is idempotent. */ +export function acquireLock(repoRoot: string, runId: string): void { + const lockPath = lockFilePath(repoRoot); + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + if (fs.existsSync(lockPath)) { + const existing = fs.readFileSync(lockPath, "utf8").trim(); + if (existing === runId) return; + throw new WorkflowLockError( + `Another workflow is active for this repository (run-id: ${existing}). ` + + `Use 'agent-workflow abort --run ${existing}' to cancel it first.`, + existing, + ); + } + fs.writeFileSync(lockPath, runId, "utf8"); +} + +/** Release the repo-level lock only when it belongs to runId. */ +export function releaseLock(repoRoot: string, runId: string): void { + const lockPath = lockFilePath(repoRoot); + if (!fs.existsSync(lockPath)) return; + const existing = fs.readFileSync(lockPath, "utf8").trim(); + if (existing === runId) fs.unlinkSync(lockPath); +} + +/** Read the active run-id, or undefined if no lock exists. */ +export function readLock(repoRoot: string): string | undefined { + const lockPath = lockFilePath(repoRoot); + if (!fs.existsSync(lockPath)) return undefined; + return fs.readFileSync(lockPath, "utf8").trim() || undefined; +} + +// --------------------------------------------------------------------------- +// Git helpers +// --------------------------------------------------------------------------- + +export class GitError extends Error { + constructor(message: string) { + super(message); + this.name = "GitError"; + } +} + +/** 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 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."); + } +} + +/** 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."); + } +} + +/** 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."); + } +} + +/** 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; +} + +// --------------------------------------------------------------------------- +// State transition helpers +// --------------------------------------------------------------------------- + +export function nowIso(): string { + return new Date().toISOString(); +} + +/** Create the initial WorkflowState for a new run. */ +export function initWorkflowState(opts: { + runId: string; + repoRoot: string; + baselineHead: string; + executor: import("./workflow-types.js").ExecutorKind; + plan: import("./workflow-types.js").WorkflowPlanV1; + maxCycles: number; + timeoutSeconds?: number; +}): WorkflowState { + const now = nowIso(); + return { + version: "1", + runId: opts.runId, + repoHash: repoHash(opts.repoRoot), + repoRoot: opts.repoRoot, + baselineHead: opts.baselineHead, + executor: opts.executor, + plan: opts.plan, + status: "executing", + maxCycles: opts.maxCycles, + currentCycle: 1, + cycles: [], + createdAt: now, + updatedAt: now, + ...(opts.timeoutSeconds !== undefined ? { timeoutSeconds: opts.timeoutSeconds } : {}), + }; +} + +/** Find a run directory by runId within the configured workflow root. */ +export function findRunDir(runId: string, repoRoot?: string): string | undefined { + const root = workflowsRoot(); + + if (repoRoot) { + const hash = repoHash(repoRoot); + const dir = path.join(root, hash, runId); + if (fs.existsSync(stateFilePath(dir))) return dir; + return undefined; + } + + if (fs.existsSync(root)) { + for (const repoHashDir of fs.readdirSync(root)) { + const dir = path.join(root, repoHashDir, runId); + if (fs.existsSync(stateFilePath(dir))) return dir; + } + } + return undefined; +} diff --git a/src/workflow-types.ts b/src/workflow-types.ts new file mode 100644 index 0000000..662ff60 --- /dev/null +++ b/src/workflow-types.ts @@ -0,0 +1,350 @@ +/** + * Workflow types for the agent-workflow host orchestration loop. + * + * WorkflowPlanV1 — input from Codex describing what to implement + * WorkflowState — durable run state (persisted as state.json) + * HostDecisionV1 — Codex decision after reviewing findings + * ExecutorKind — the three supported executor adapters + * WorkflowStatus — machine-readable status summary + */ + +// --------------------------------------------------------------------------- +// Executors +// --------------------------------------------------------------------------- + +export const EXECUTOR_KINDS = ["reasonix", "claude", "agy"] as const; +export type ExecutorKind = (typeof EXECUTOR_KINDS)[number]; + +// --------------------------------------------------------------------------- +// Plan (input from Codex) +// --------------------------------------------------------------------------- + +export interface WorkflowPlanV1 { + /** Schema version — always "1". */ + version: "1"; + title: string; + planMarkdown: string; + /** Allowed repository-relative paths (files and/or directories). */ + scope: string[]; + acceptanceCriteria: string[]; + /** Shell commands used to verify the implementation (run without shell expansion). */ + verificationCommands: string[][]; +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +export const WORKFLOW_ACTIVE_STATUSES = [ + "executing", + "awaiting_review", + "awaiting_scope_resolution", + "awaiting_host", +] as const; + +export const WORKFLOW_TERMINAL_STATUSES = [ + "completed", + "needs_human", + "blocked", + "budget_exhausted", + "aborted", +] as const; + +export const WORKFLOW_STATUSES = [ + ...WORKFLOW_ACTIVE_STATUSES, + ...WORKFLOW_TERMINAL_STATUSES, +] as const; + +export type WorkflowActiveStatus = (typeof WORKFLOW_ACTIVE_STATUSES)[number]; +export type WorkflowTerminalStatus = (typeof WORKFLOW_TERMINAL_STATUSES)[number]; +export type WorkflowStatus = (typeof WORKFLOW_STATUSES)[number]; + +// --------------------------------------------------------------------------- +// Session handles per executor kind +// --------------------------------------------------------------------------- + +export interface ReasonixSessionHandle { + kind: "reasonix"; + /** Absolute path to the .jsonl session file detected after the first run. */ + sessionFilePath: string; +} + +export interface ClaudeSessionHandle { + kind: "claude"; + sessionId: string; +} + +export interface AgySessionHandle { + kind: "agy"; + /** Conversation ID from agy output, if available. */ + conversationId?: string; + /** True when we had to fall back to --continue because no ID was captured. */ + degraded: boolean; +} + +export type ExecutorSessionHandle = + | ReasonixSessionHandle + | ClaudeSessionHandle + | AgySessionHandle; + +export interface ScopeViolation { + id: string; + path: string; + kind: "out_of_scope"; +} + +// --------------------------------------------------------------------------- +// Per-cycle record +// --------------------------------------------------------------------------- + +export interface CycleRecord { + cycleIndex: number; + startedAt: string; + completedAt?: string; + /** Exit code returned by the executor process. */ + executorExitCode?: number; + /** Executor's text report (truncated summary). */ + executorReport?: string; + /** Path to the complete executor log (relative to run dir). */ + executorLogFile?: string; + /** All executor attempt logs for this cycle (including retries). */ + executorAttemptLogs?: string[]; + /** Path to the diff file generated after this cycle (relative to run dir). */ + diffFile?: string; + /** Path to the Codex host review bundle (relative to run dir). */ + hostReviewFile?: string; + /** Time at which the review bundle became ready for Codex. */ + hostReviewReadyAt?: string; + /** Files that failed the frozen scope gate before Codex review. */ + scopeViolations?: ScopeViolation[]; + /** Path to the Codex decision file (relative to run dir). */ + decisionFile?: string; + /** Codex decision outcome for this cycle. */ + decisionOutcome?: HostDecisionOutcome; + /** Cycle-level stop reason when this cycle triggered termination. */ + stopReason?: WorkflowTerminalStatus; +} + +// --------------------------------------------------------------------------- +// Workflow state (persisted) +// --------------------------------------------------------------------------- + +export interface WorkflowState { + /** Schema version — always "1". */ + version: "1"; + runId: string; + repoHash: string; + /** Absolute path to the repository root. */ + repoRoot: string; + /** Git HEAD SHA at workflow start. */ + baselineHead: string; + executor: ExecutorKind; + executorConfig?: ExecutorConfig; + timeoutSeconds?: number; + enginePid?: number; + plan: WorkflowPlanV1; + status: WorkflowStatus; + /** When the run reached a terminal status, the stop reason (same as status for terminals). */ + stopReason?: WorkflowTerminalStatus; + /** Human-readable stop description. */ + stopDescription?: string; + maxCycles: number; + currentCycle: number; + cycles: CycleRecord[]; + sessionHandle?: ExecutorSessionHandle; + createdAt: string; + updatedAt: string; + /** True when --agy-degraded-continue is in effect. */ + agyDegraded?: boolean; +} + +// --------------------------------------------------------------------------- +// Host decision (from Codex) +// --------------------------------------------------------------------------- + +export const HOST_DECISION_OUTCOMES = ["fix", "accept", "needs_human"] as const; +export type HostDecisionOutcome = (typeof HOST_DECISION_OUTCOMES)[number]; + +export interface FindingDecision { + /** Stable id assigned by Codex, or scope-* for a scope violation. */ + findingId: string; + /** "accept" = fix it, "reject" = acknowledged but out of scope, "followup" = record and move on. */ + disposition: "accept" | "reject" | "followup"; + /** Concrete issue description. Required for accepted Codex findings. */ + summary?: string; + /** Repository-relative location when known. */ + path?: string; + rationale?: string; +} + +export interface HostDecisionV1 { + /** Schema version — always "1". */ + version: "1"; + outcome: HostDecisionOutcome; + /** Findings Codex decided on. */ + findingDecisions: FindingDecision[]; + /** Reason for needs_human or rejection rationale. */ + reason?: string; + /** Follow-up items to record (not to fix now). */ + followUps?: string[]; + /** Codex-supplied verification evidence (command outputs, test results). */ + verificationEvidence?: string[]; + /** ISO timestamp when Codex submitted this decision. */ + decidedAt: string; +} + +/** Persisted evidence package that Codex must inspect before deciding. */ +export interface HostReviewBundleV1 { + version: "1"; + runId: string; + cycleIndex: number; + repoRoot: string; + baselineHead: string; + generatedAt: string; + plan: WorkflowPlanV1; + diffFile: string; + executorLogFile?: string; + executorReport?: string; + verificationCommands: string[][]; +} + +// --------------------------------------------------------------------------- +// Workflow config (agent-workflow.json) +// --------------------------------------------------------------------------- + +export interface ExecutorConfig { + binary?: string; + model?: string; + agent?: string; + profile?: string; + /** Token budget (executor-specific interpretation). */ + budget?: number; +} + +export interface WorkflowProjectConfig { + version: "1"; + defaultExecutor?: ExecutorKind; + maxCycles?: number; + timeoutSeconds?: number; + executors?: { + reasonix?: ExecutorConfig; + claude?: ExecutorConfig; + agy?: ExecutorConfig; + }; +} + +// --------------------------------------------------------------------------- +// Resolved workflow config (after merging CLI + project + defaults) +// --------------------------------------------------------------------------- + +export interface ResolvedWorkflowConfig { + executor: ExecutorKind; + maxCycles: number; + timeoutSeconds: number; + executorConfig: ExecutorConfig; +} + +// --------------------------------------------------------------------------- +// Status output +// --------------------------------------------------------------------------- + +export interface WorkflowStatusOutput { + runId: string; + status: WorkflowStatus; + executor: ExecutorKind; + currentCycle: number; + maxCycles: number; + remainingCycles: number; + sessionHandleKind?: string; + degradedResume?: boolean; + runDir: string; + diffFile?: string; + executorLogFile?: string; + hostReviewFile?: string; + scopeViolations?: ScopeViolation[]; + stopReason?: WorkflowTerminalStatus; + stopDescription?: string; + baselineHead: string; + repoRoot: string; + createdAt: string; + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Event log (events.jsonl — append-only audit trail) +// --------------------------------------------------------------------------- + +export type WorkflowEventKind = + | "workflow_started" + | "cycle_started" + | "executor_started" + | "executor_retried" + | "executor_completed" + | "review_started" + | "review_completed" + | "scope_violation_detected" + | "scope_resolution_started" + | "review_retried" + | "decision_received" + | "cycle_completed" + | "workflow_stopped" + | "workflow_aborted" + | "blocked"; + +export interface WorkflowEvent { + kind: WorkflowEventKind; + runId: string; + timestamp: string; + cycleIndex?: number; + data?: Record; +} + +// --------------------------------------------------------------------------- +// Executor adapter interface +// --------------------------------------------------------------------------- + +export interface ExecutorResult { + /** Process exit code. */ + exitCode: number | null; + /** Combined text report from the executor. */ + report: string; + durationMs: number; + /** Updated session handle after this run. */ + sessionHandle?: ExecutorSessionHandle; + /** True when the executor process terminated abnormally. */ + failed: boolean; + failureReason?: string; +} + +export interface ExecutorAdapter { + kind: ExecutorKind; + /** Check if the executor binary is available. */ + probe(config?: ExecutorConfig): Promise; + /** Start a new implementation session. Returns session handle + result. */ + start(opts: ExecutorStartOpts): Promise; + /** Resume an existing session for a fix cycle. */ + resume(opts: ExecutorResumeOpts): Promise; + /** Cancel a running session. */ + cancel(handle: ExecutorSessionHandle): Promise; +} + +export interface ExecutorStartOpts { + repoRoot: string; + plan: WorkflowPlanV1; + runDir: string; + cycleIndex: number; + config: ExecutorConfig; + signal?: AbortSignal; +} + +export interface ExecutorResumeOpts { + repoRoot: string; + plan: WorkflowPlanV1; + /** Only the accepted (actionable) findings sent to the executor. */ + acceptedFindings: string; + handle: ExecutorSessionHandle; + runDir: string; + cycleIndex: number; + config: ExecutorConfig; + signal?: AbortSignal; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a0f4da7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1b8533d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +/** + * Vitest config for the agent-workflow package. + * + * - node environment (CLI unit tests, no DOM). + * - Tests live under src/. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + reporters: ["default"], + // The signal/abort timing tests in child-process.test.ts spawn real + // subprocesses and are wall-clock sensitive; under parallel load the abort + // grace margin can slip. Retry absorbs that flakiness without forcing the + // whole suite to run serially (which would cost ~10s). + retry: 2, + }, +});