From 8e53c40a1b1b8abf80d44f2223327cc5be5646b5 Mon Sep 17 00:00:00 2001 From: Dave Johnson Date: Wed, 22 Jul 2026 23:01:17 -0700 Subject: [PATCH] Remove legacy runtime autofix agent --- .github/workflows/autofix.yml | 67 ---- .gitignore | 3 - docs/TOOLS.md | 4 +- docs/github-infrastructure.md | 13 + package-lock.json | 249 +----------- package.json | 2 - src/agent/__tests__/cloudrun.watcher.test.ts | 145 ------- src/agent/__tests__/code-context.test.ts | 185 --------- src/agent/__tests__/code-fixer.test.ts | 258 ------------- src/agent/__tests__/config.test.ts | 78 ---- src/agent/__tests__/git-ops.test.ts | 205 ---------- src/agent/__tests__/pr-templates.test.ts | 253 ------------- src/agent/__tests__/prompts.test.ts | 112 ------ src/agent/__tests__/state.test.ts | 358 ------------------ src/agent/__tests__/types.test.ts | 255 ------------- src/agent/__tests__/validators.test.ts | 67 ---- src/agent/analyzer/code-context.ts | 203 ---------- src/agent/analyzer/error-analyzer.ts | 108 ------ src/agent/analyzer/prompts.ts | 133 ------- src/agent/autofix-agent.ts | 260 ------------- src/agent/config.ts | 56 --- src/agent/fixer/code-fixer.ts | 194 ---------- src/agent/fixer/git-ops.ts | 202 ---------- src/agent/fixer/validators.ts | 131 ------- src/agent/github/pr-creator.ts | 162 -------- src/agent/github/pr-templates.ts | 125 ------ src/agent/index.ts | 60 --- src/agent/state.ts | 286 -------------- src/agent/watchers/cloudrun.watcher.ts | 117 ------ src/agent/watchers/log-watcher.ts | 30 -- src/agent/watchers/railway.watcher.ts | 139 ------- src/agent/watchers/types.ts | 186 --------- .../spec/__tests__/github.schema.test.ts | 15 + src/domain/spec/spec.schema.ts | 15 +- .../__tests__/hv-observability.tools.test.ts | 35 +- src/tools/apply-plan.ts | 33 -- src/tools/hv-observability.tools.ts | 40 +- 37 files changed, 69 insertions(+), 4715 deletions(-) delete mode 100644 .github/workflows/autofix.yml delete mode 100644 src/agent/__tests__/cloudrun.watcher.test.ts delete mode 100644 src/agent/__tests__/code-context.test.ts delete mode 100644 src/agent/__tests__/code-fixer.test.ts delete mode 100644 src/agent/__tests__/config.test.ts delete mode 100644 src/agent/__tests__/git-ops.test.ts delete mode 100644 src/agent/__tests__/pr-templates.test.ts delete mode 100644 src/agent/__tests__/prompts.test.ts delete mode 100644 src/agent/__tests__/state.test.ts delete mode 100644 src/agent/__tests__/types.test.ts delete mode 100644 src/agent/__tests__/validators.test.ts delete mode 100644 src/agent/analyzer/code-context.ts delete mode 100644 src/agent/analyzer/error-analyzer.ts delete mode 100644 src/agent/analyzer/prompts.ts delete mode 100644 src/agent/autofix-agent.ts delete mode 100644 src/agent/config.ts delete mode 100644 src/agent/fixer/code-fixer.ts delete mode 100644 src/agent/fixer/git-ops.ts delete mode 100644 src/agent/fixer/validators.ts delete mode 100644 src/agent/github/pr-creator.ts delete mode 100644 src/agent/github/pr-templates.ts delete mode 100644 src/agent/index.ts delete mode 100644 src/agent/state.ts delete mode 100644 src/agent/watchers/cloudrun.watcher.ts delete mode 100644 src/agent/watchers/log-watcher.ts delete mode 100644 src/agent/watchers/railway.watcher.ts delete mode 100644 src/agent/watchers/types.ts diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml deleted file mode 100644 index 87296f0..0000000 --- a/.github/workflows/autofix.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Auto-Fix Agent: Monitors production logs, analyzes errors with Claude, creates PRs with fixes -name: Auto-Fix Agent - -on: - schedule: - - cron: '0 * * * *' # Every hour - workflow_dispatch: # Manual trigger - inputs: - dry_run: - description: 'Dry run (analyze but do not create PRs)' - required: false - default: 'false' - type: boolean - -jobs: - autofix: - runs-on: ubuntu-latest - # Prevent concurrent runs to avoid duplicate PRs - concurrency: - group: autofix-${{ github.ref }} - cancel-in-progress: false - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - # Need full history for proper git operations - fetch-depth: 0 - # Use a PAT for pushing commits (GITHUB_TOKEN can't trigger workflows) - token: ${{ secrets.AUTOFIX_PAT || github.token }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Run auto-fix agent - id: autofix - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} - GCP_SERVICE_ACCOUNT_JSON: ${{ secrets.GCP_SERVICE_ACCOUNT_JSON }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AUTOFIX_DRY_RUN: ${{ inputs.dry_run || 'false' }} - run: npm run autofix - - - name: Commit state changes - if: always() - run: | - git config user.name "Auto-Fix Agent" - git config user.email "autofix@infraprint.dev" - - # Only commit if state file exists and has changes - if [ -f autofix-state.json ]; then - git add autofix-state.json - if ! git diff --staged --quiet; then - git commit -m "chore: update autofix state [skip ci]" - git push - fi - fi diff --git a/.gitignore b/.gitignore index cbfcabe..cd3b2ae 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,3 @@ data/*.db-journal data/*.db-shm data/*.db-wal data/.secret-key - -# Local agent state -autofix-state.json diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e6eda25..60a2286 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # Tool Catalog -Generated from the live server at `2026-07-22T21:42:51.331Z`. +Generated from the live server at `2026-07-23T05:51:06.344Z`. Total tools: **42** @@ -25,7 +25,7 @@ _Do not edit this file manually. Re-generate with `npm run build && npm run docs ## Deploy & observability - `hv_deploy` - Deploy services to an environment (staging, production, etc.). Plan-gated: builds a plan from the spec and applies it immediately; the planId and applyRunId are returned for the audit trail. Delegated secret slots accept values only through secretRefs={KEY:"env:NAME"|"dotenv:/absolute/path/.env#KEY"|"file:/absolute/path"|"://..."}; values are resolved locally and encrypted into the plan. Ordinary envVars and env files cannot override delegated keys. By default, .env. then repo .env are considered as deploy input in envFile.mode="runtime". Requires a spec (hv_spec_set). Protected environments require confirm=true. -- `hv_errors` - Surface production errors: list recent error log lines, summarize error health per service, or manage autofix-tracked error fingerprints. +- `hv_errors` - Surface production errors: list recent error log lines or summarize error and deployment health per service. - `hv_health` - HTTP health-check a deployed service (uses the stored healthCheckPath by default) or an explicit URL. - `hv_logs` - Fetch logs and delivery status: runtime service logs, build logs, recent deployments, or Stripe webhook endpoint status. - `hv_rollback` - Rollback by redeploying services from the most recent successful deploy run (or a specific run via toRunId). Recorded as a plan/apply run pair (planId + applyRunId returned) with per-service receipts; redeploys current code, not a pinned image. Protected environments require confirm=true. diff --git a/docs/github-infrastructure.md b/docs/github-infrastructure.md index 1a2b892..5931bf6 100644 --- a/docs/github-infrastructure.md +++ b/docs/github-infrastructure.md @@ -186,3 +186,16 @@ Add an IANA timezone such as `America/Vancouver`; omitted timezones default to state and tells you to run `hv_plan`; it no longer writes GitHub directly. Use `hv_status` after a successful deploy workflow to verify the actual service. + +## Runtime error visibility + +GitHub workflow autofix repairs failed checks; it does not poll production +service logs. Use `hv_errors action="list"` for recent runtime error lines and +`hv_errors action="summary"` for per-service error and deployment health. Both +read through the configured hosting provider connection and do not create +branches or pull requests. + +The former environment-level `environments..autofix` runtime repair agent +has been removed. Existing specs that contain it fail validation with migration +guidance instead of silently losing intent. Scheduled runtime-error alerts or a +desktop error inbox can be added later as a separate desired-state capability. diff --git a/package-lock.json b/package-lock.json index e501a3e..5282f35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "dependencies": { "@1password/sdk": "^0.4.0", - "@anthropic-ai/sdk": "^0.30.0", "@aws-sdk/client-ec2": "^3.1092.0", "@aws-sdk/client-rds": "^3.1092.0", "@bitwarden/sdk-napi": "^1.0.0", @@ -57,36 +56,6 @@ "integrity": "sha512-vjeI1o4wiONY+t1naA4dtUp6HktdLH1D2S+tN1Lh4l41S9XIUHxrljov9B5u6G+VHr7f2MUoxmzXA9zT3aokQQ==", "license": "MIT" }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.30.1.tgz", - "integrity": "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/@aws-sdk/client-ec2": { "version": "3.1092.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1092.0.tgz", @@ -1161,21 +1130,12 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/pg": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", @@ -1332,18 +1292,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1366,18 +1314,6 @@ "node": ">= 14" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1457,12 +1393,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1730,18 +1660,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1870,15 +1788,6 @@ "node": ">=4.0.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1993,21 +1902,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2033,15 +1927,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -2265,62 +2150,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2653,21 +2482,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2729,15 +2543,6 @@ "node": ">= 14" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -3420,26 +3225,6 @@ "node": ">=10.5.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4479,12 +4264,6 @@ "node": ">=0.6" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4568,6 +4347,7 @@ "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/unpipe": { @@ -4768,31 +4548,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 98e4e58..d0e2403 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,11 @@ "docs:tools": "node scripts/generate-tools-doc.mjs", "release:check": "npm test && npm run typecheck && npm run build && npm run prepublish-check", "release": "node scripts/release.mjs", - "autofix": "node dist/agent/index.js", "prepublish-check": "bash scripts/prepublish-check.sh", "prepublishOnly": "npm run build && npm run prepublish-check" }, "dependencies": { "@1password/sdk": "^0.4.0", - "@anthropic-ai/sdk": "^0.30.0", "@aws-sdk/client-ec2": "^3.1092.0", "@aws-sdk/client-rds": "^3.1092.0", "@bitwarden/sdk-napi": "^1.0.0", diff --git a/src/agent/__tests__/cloudrun.watcher.test.ts b/src/agent/__tests__/cloudrun.watcher.test.ts deleted file mode 100644 index 75e570d..0000000 --- a/src/agent/__tests__/cloudrun.watcher.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { initializeDatabase, SqliteAdapter } from '../../adapters/db/sqlite.adapter.js'; -import { ProjectRepository } from '../../adapters/db/repositories/project.repository.js'; -import { EnvironmentRepository } from '../../adapters/db/repositories/environment.repository.js'; -import { ConnectionRepository } from '../../adapters/db/repositories/connection.repository.js'; -import { getSecretStore } from '../../adapters/secrets/secret-store.js'; -import { CloudRunAdapter } from '../../adapters/providers/gcp/cloudrun.adapter.js'; -import { CloudRunLogWatcher } from '../watchers/cloudrun.watcher.js'; - -describe('CloudRunLogWatcher', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hypervibe-cloudrun-watcher-')); - SqliteAdapter.resetInstance(); - initializeDatabase(path.join(tempDir, 'hypervibe.db')); - }); - - afterEach(() => { - vi.restoreAllMocks(); - SqliteAdapter.resetInstance(); - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - function seedCloudRunConnection(): void { - const secretStore = getSecretStore(); - const repo = new ConnectionRepository(); - const connection = repo.create({ - provider: 'cloudrun', - credentialsEncrypted: secretStore.encryptObject({ - credentials: '{"type":"service_account"}', - projectId: 'gcp-project-1', - region: 'us-central1', - }), - }); - repo.updateStatus(connection.id, 'verified'); - } - - function seedEnvironment(provider: string): { projectId: string; environmentId: string } { - const project = new ProjectRepository().create({ name: `app-${provider}`, defaultPlatform: provider }); - const environment = new EnvironmentRepository().create({ - projectId: project.id, - name: 'production', - platformBindings: { - provider, - projectId: 'gcp-project-1', - environmentId: 'production', - services: { - web: { serviceId: 'web-prod', resourceType: 'service' }, - }, - }, - }); - return { projectId: project.id, environmentId: environment.id }; - } - - it('create() returns null without a cloudrun connection', async () => { - expect(await CloudRunLogWatcher.create()).toBeNull(); - }); - - it('create() connects and verifies through the adapter', async () => { - seedCloudRunConnection(); - const connect = vi.spyOn(CloudRunAdapter.prototype, 'connect').mockResolvedValue(undefined); - const verify = vi.spyOn(CloudRunAdapter.prototype, 'verify').mockResolvedValue({ success: true }); - - const watcher = await CloudRunLogWatcher.create(); - expect(watcher).not.toBeNull(); - expect(connect).toHaveBeenCalledTimes(1); - expect(verify).toHaveBeenCalledTimes(1); - }); - - it('create() returns null when verification fails', async () => { - seedCloudRunConnection(); - vi.spyOn(CloudRunAdapter.prototype, 'connect').mockResolvedValue(undefined); - vi.spyOn(CloudRunAdapter.prototype, 'verify').mockResolvedValue({ success: false, error: 'bad key' }); - - expect(await CloudRunLogWatcher.create()).toBeNull(); - }); - - it('canHandle() is true only for projects with cloudrun bindings', async () => { - seedCloudRunConnection(); - vi.spyOn(CloudRunAdapter.prototype, 'connect').mockResolvedValue(undefined); - vi.spyOn(CloudRunAdapter.prototype, 'verify').mockResolvedValue({ success: true }); - const cloudrun = seedEnvironment('cloudrun'); - const railway = seedEnvironment('railway'); - - const watcher = (await CloudRunLogWatcher.create())!; - expect(await watcher.canHandle(cloudrun.projectId)).toBe(true); - expect(await watcher.canHandle(railway.projectId)).toBe(false); - }); - - it('fetchErrors() normalizes grouped error logs from adapter.getLogs', async () => { - seedCloudRunConnection(); - vi.spyOn(CloudRunAdapter.prototype, 'connect').mockResolvedValue(undefined); - vi.spyOn(CloudRunAdapter.prototype, 'verify').mockResolvedValue({ success: true }); - const { projectId, environmentId } = seedEnvironment('cloudrun'); - - const getLogs = vi.spyOn(CloudRunAdapter.prototype, 'getLogs').mockResolvedValue([ - { - timestamp: new Date('2026-07-01T10:00:00Z'), - message: 'TypeError: Cannot read properties of undefined', - severity: 'error', - raw: 'TypeError: Cannot read properties of undefined', - }, - { - timestamp: new Date('2026-07-01T10:00:00Z'), - message: ' at handler (/app/dist/server.js:10:5)', - severity: 'error', - raw: ' at handler (/app/dist/server.js:10:5)', - }, - ]); - - const watcher = (await CloudRunLogWatcher.create())!; - const errors = await watcher.fetchErrors(environmentId, 'web', { limit: 5 }); - - expect(getLogs).toHaveBeenCalledWith( - expect.objectContaining({ id: environmentId }), - 'web', - expect.objectContaining({ errorsOnly: true }) - ); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - message: 'TypeError: Cannot read properties of undefined', - errorType: 'TypeError', - serviceName: 'web', - environmentName: 'production', - projectId, - }); - expect(errors[0].stackTrace).toContain('at handler'); - }); - - it('fetchErrors() returns [] for environments not bound to cloudrun', async () => { - seedCloudRunConnection(); - vi.spyOn(CloudRunAdapter.prototype, 'connect').mockResolvedValue(undefined); - vi.spyOn(CloudRunAdapter.prototype, 'verify').mockResolvedValue({ success: true }); - const getLogs = vi.spyOn(CloudRunAdapter.prototype, 'getLogs'); - const railway = seedEnvironment('railway'); - - const watcher = (await CloudRunLogWatcher.create())!; - expect(await watcher.fetchErrors(railway.environmentId, 'web')).toEqual([]); - expect(getLogs).not.toHaveBeenCalled(); - }); -}); diff --git a/src/agent/__tests__/code-context.test.ts b/src/agent/__tests__/code-context.test.ts deleted file mode 100644 index 999b13a..0000000 --- a/src/agent/__tests__/code-context.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { extractCodeContext, findRelatedFiles } from '../analyzer/code-context.js'; - -describe('code-context', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'code-context-test-')); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('extractCodeContext', () => { - it('extracts code from stack trace paths', async () => { - // Create a source file - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() { return 42; }'); - - const files = await extractCodeContext( - tempDir, - 'TypeError: x is undefined', - 'at foo (src/service.ts:1:5)' - ); - - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/service.ts'); - expect(files[0].content).toContain('foo'); - }); - - it('handles multiple files in stack trace', async () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/a.ts'), 'export const a = 1;'); - writeFileSync(join(tempDir, 'src/b.ts'), 'export const b = 2;'); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (src/a.ts:1:1)\n at bar (src/b.ts:1:1)' - ); - - expect(files).toHaveLength(2); - }); - - it('limits to 5 files', async () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - for (let i = 0; i < 10; i++) { - writeFileSync(join(tempDir, `src/file${i}.ts`), `export const x${i} = ${i};`); - } - - const stackTrace = Array.from({ length: 10 }, (_, i) => - `at fn (src/file${i}.ts:1:1)` - ).join('\n'); - - const files = await extractCodeContext(tempDir, 'Error', stackTrace); - - expect(files.length).toBeLessThanOrEqual(5); - }); - - it('truncates large files', async () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - const largeContent = 'x'.repeat(20000); - writeFileSync(join(tempDir, 'src/large.ts'), largeContent); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (src/large.ts:1:1)' - ); - - expect(files).toHaveLength(1); - expect(files[0].content.length).toBeLessThan(largeContent.length); - expect(files[0].content).toContain('truncated'); - }); - - it('skips node_modules paths', async () => { - mkdirSync(join(tempDir, 'node_modules/pkg'), { recursive: true }); - writeFileSync(join(tempDir, 'node_modules/pkg/index.js'), 'module.exports = {}'); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (node_modules/pkg/index.js:1:1)' - ); - - expect(files).toHaveLength(0); - }); - - it('handles non-existent files gracefully', async () => { - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (src/nonexistent.ts:1:1)' - ); - - expect(files).toHaveLength(0); - }); - - it('maps dist paths to src', async () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (dist/service.js:1:1)' - ); - - expect(files).toHaveLength(1); - expect(files[0].path).toBe('src/service.ts'); - }); - - it('deduplicates files', async () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'at foo (src/service.ts:1:1)\n at bar (src/service.ts:5:1)' - ); - - expect(files).toHaveLength(1); - }); - - it('extracts paths from Python stack traces', async () => { - mkdirSync(join(tempDir, 'app'), { recursive: true }); - writeFileSync(join(tempDir, 'app/main.py'), 'def main(): pass'); - - const files = await extractCodeContext( - tempDir, - 'Error', - 'File "app/main.py", line 10, in main' - ); - - expect(files).toHaveLength(1); - expect(files[0].path).toBe('app/main.py'); - }); - }); - - describe('findRelatedFiles', () => { - it('finds test files', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - writeFileSync(join(tempDir, 'src/service.test.ts'), 'test("foo", () => {})'); - - const related = findRelatedFiles(tempDir, 'src/service.ts'); - - expect(related).toContain('src/service.test.ts'); - }); - - it('finds spec files', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - writeFileSync(join(tempDir, 'src/service.spec.ts'), 'describe("foo", () => {})'); - - const related = findRelatedFiles(tempDir, 'src/service.ts'); - - expect(related).toContain('src/service.spec.ts'); - }); - - it('finds type definition files', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - writeFileSync(join(tempDir, 'src/service.types.ts'), 'export interface Foo {}'); - - const related = findRelatedFiles(tempDir, 'src/service.ts'); - - expect(related).toContain('src/service.types.ts'); - }); - - it('returns empty array when no related files exist', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {}'); - - const related = findRelatedFiles(tempDir, 'src/service.ts'); - - expect(related).toEqual([]); - }); - }); -}); diff --git a/src/agent/__tests__/code-fixer.test.ts b/src/agent/__tests__/code-fixer.test.ts deleted file mode 100644 index b11cf47..0000000 --- a/src/agent/__tests__/code-fixer.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { execSync } from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { CodeFixer } from '../fixer/code-fixer.js'; -import type { SuggestedFix } from '../analyzer/prompts.js'; -import type { AutoFixConfig } from '../config.js'; - -describe('CodeFixer', () => { - let tempDir: string; - let bareDir: string; - let fixer: CodeFixer; - let config: AutoFixConfig; - - beforeEach(() => { - // Create temp directory with a git repo - tempDir = mkdtempSync(join(tmpdir(), 'code-fixer-test-')); - - // Create bare repo for remote - bareDir = mkdtempSync(join(tmpdir(), 'code-fixer-bare-')); - execSync('git init --bare', { cwd: bareDir }); - - // Initialize git repo with explicit branch name for consistency - execSync('git init -b master', { cwd: tempDir }); - execSync('git config user.email "test@example.com"', { cwd: tempDir }); - execSync('git config user.name "Test"', { cwd: tempDir }); - - // Create initial structure - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/service.ts'), 'export function foo() {\n return obj.x;\n}'); - writeFileSync(join(tempDir, 'README.md'), '# Test'); - - // Initial commit - execSync('git add .', { cwd: tempDir }); - execSync('git commit -m "Initial commit"', { cwd: tempDir }); - - // Add remote - execSync(`git remote add origin ${bareDir}`, { cwd: tempDir }); - execSync('git push -u origin master', { cwd: tempDir }); - - config = { - workingDirectory: tempDir, - gitUserName: 'Test Bot', - gitUserEmail: 'bot@example.com', - anthropicApiKey: 'test', - claudeModel: 'test', - pollIntervalSeconds: 300, - maxErrorsPerPoll: 10, - maxPRsPerHour: 5, - cooldownSeconds: 3600, - dryRun: false, - }; - - fixer = new CodeFixer(config); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - rmSync(bareDir, { recursive: true, force: true }); - }); - - describe('applyFix', () => { - it('applies simple replace fix', async () => { - const fix: SuggestedFix = { - description: 'Add optional chaining', - files: [ - { - path: 'src/service.ts', - changes: [ - { - type: 'replace', - search: 'obj.x', - replace: 'obj?.x', - }, - ], - }, - ], - }; - - const result = await fixer.applyFix(fix, 'fingerprint123'); - - expect(result.success).toBe(true); - expect(result.branchName).toBe('autofix/err-fingerprint123'); - expect(result.filesChanged).toContain('src/service.ts'); - - // Verify file was changed - execSync(`git checkout ${result.branchName}`, { cwd: tempDir }); - const content = readFileSync(join(tempDir, 'src/service.ts'), 'utf-8'); - expect(content).toContain('obj?.x'); - }); - - it('fails when branch already exists', async () => { - // Create the branch first - execSync('git branch autofix/err-existing', { cwd: tempDir }); - - const fix: SuggestedFix = { - description: 'Test', - files: [], - }; - - const result = await fixer.applyFix(fix, 'existing'); - - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); - }); - - it('fails when file not found', async () => { - const fix: SuggestedFix = { - description: 'Test', - files: [ - { - path: 'src/nonexistent.ts', - changes: [{ type: 'replace', search: 'x', replace: 'y' }], - }, - ], - }; - - const result = await fixer.applyFix(fix, 'fp1'); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('fails when search string not found', async () => { - const fix: SuggestedFix = { - description: 'Test', - files: [ - { - path: 'src/service.ts', - changes: [{ type: 'replace', search: 'nonexistent string', replace: 'y' }], - }, - ], - }; - - const result = await fixer.applyFix(fix, 'fp2'); - - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('applies multiple changes to same file', async () => { - writeFileSync(join(tempDir, 'src/service.ts'), 'const a = 1;\nconst b = 2;'); - execSync('git add .', { cwd: tempDir }); - execSync('git commit -m "Update"', { cwd: tempDir }); - execSync('git push origin master', { cwd: tempDir }); - - const fix: SuggestedFix = { - description: 'Update constants', - files: [ - { - path: 'src/service.ts', - changes: [ - { type: 'replace', search: 'const a = 1', replace: 'const a = 10' }, - { type: 'replace', search: 'const b = 2', replace: 'const b = 20' }, - ], - }, - ], - }; - - const result = await fixer.applyFix(fix, 'fp3'); - - expect(result.success).toBe(true); - - execSync(`git checkout ${result.branchName}`, { cwd: tempDir }); - const content = readFileSync(join(tempDir, 'src/service.ts'), 'utf-8'); - expect(content).toContain('const a = 10'); - expect(content).toContain('const b = 20'); - }); - - it('applies changes to multiple files', async () => { - writeFileSync(join(tempDir, 'src/a.ts'), 'export const a = 1;'); - writeFileSync(join(tempDir, 'src/b.ts'), 'export const b = 2;'); - execSync('git add .', { cwd: tempDir }); - execSync('git commit -m "Add files"', { cwd: tempDir }); - execSync('git push origin master', { cwd: tempDir }); - - const fix: SuggestedFix = { - description: 'Update both files', - files: [ - { path: 'src/a.ts', changes: [{ type: 'replace', search: 'a = 1', replace: 'a = 10' }] }, - { path: 'src/b.ts', changes: [{ type: 'replace', search: 'b = 2', replace: 'b = 20' }] }, - ], - }; - - const result = await fixer.applyFix(fix, 'fp4'); - - expect(result.success).toBe(true); - expect(result.filesChanged).toHaveLength(2); - }); - - it('handles delete change type', async () => { - writeFileSync(join(tempDir, 'src/service.ts'), '// TODO: remove this\nexport const x = 1;'); - execSync('git add .', { cwd: tempDir }); - execSync('git commit -m "Update"', { cwd: tempDir }); - execSync('git push origin master', { cwd: tempDir }); - - const fix: SuggestedFix = { - description: 'Remove TODO comment', - files: [ - { - path: 'src/service.ts', - changes: [{ type: 'delete', search: '// TODO: remove this\n' }], - }, - ], - }; - - const result = await fixer.applyFix(fix, 'fp5'); - - expect(result.success).toBe(true); - - execSync(`git checkout ${result.branchName}`, { cwd: tempDir }); - const content = readFileSync(join(tempDir, 'src/service.ts'), 'utf-8'); - expect(content).not.toContain('TODO'); - }); - - it('returns to original branch after success', async () => { - const originalBranch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: tempDir, - encoding: 'utf-8', - }).trim(); - - const fix: SuggestedFix = { - description: 'Test', - files: [ - { - path: 'src/service.ts', - changes: [{ type: 'replace', search: 'obj.x', replace: 'obj?.x' }], - }, - ], - }; - - await fixer.applyFix(fix, 'fp6'); - - const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { - cwd: tempDir, - encoding: 'utf-8', - }).trim(); - - expect(currentBranch).toBe(originalBranch); - }); - - it('cleans up on failure', async () => { - const fix: SuggestedFix = { - description: 'Test', - files: [ - { path: 'src/nonexistent.ts', changes: [{ type: 'replace', search: 'x', replace: 'y' }] }, - ], - }; - - await fixer.applyFix(fix, 'fp7'); - - // Branch should not exist after failure cleanup - const branches = execSync('git branch', { cwd: tempDir, encoding: 'utf-8' }); - expect(branches).not.toContain('autofix/err-fp7'); - }); - }); -}); diff --git a/src/agent/__tests__/config.test.ts b/src/agent/__tests__/config.test.ts deleted file mode 100644 index 50eaf4a..0000000 --- a/src/agent/__tests__/config.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { loadConfig } from '../config.js'; - -describe('config', () => { - const originalEnv = process.env; - - beforeEach(() => { - // Reset environment - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - describe('loadConfig', () => { - it('throws when ANTHROPIC_API_KEY is missing', () => { - delete process.env.ANTHROPIC_API_KEY; - expect(() => loadConfig()).toThrow('ANTHROPIC_API_KEY environment variable is required'); - }); - - it('loads config with required API key', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - const config = loadConfig(); - expect(config.anthropicApiKey).toBe('test-key'); - }); - - it('uses default values when optional vars not set', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - const config = loadConfig(); - - expect(config.claudeModel).toBe('claude-sonnet-4-20250514'); - expect(config.pollIntervalSeconds).toBe(300); - expect(config.maxErrorsPerPoll).toBe(10); - expect(config.maxPRsPerHour).toBe(5); - expect(config.cooldownSeconds).toBe(3600); - expect(config.gitUserName).toBe('Auto-Fix Agent'); - expect(config.gitUserEmail).toBe('autofix@hypervibe.dev'); - expect(config.dryRun).toBe(false); - }); - - it('respects custom environment variables', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AUTOFIX_CLAUDE_MODEL = 'claude-3-opus'; - process.env.AUTOFIX_POLL_INTERVAL = '600'; - process.env.AUTOFIX_MAX_ERRORS_PER_POLL = '20'; - process.env.AUTOFIX_MAX_PRS_PER_HOUR = '10'; - process.env.AUTOFIX_COOLDOWN_SECONDS = '7200'; - process.env.AUTOFIX_GIT_USER_NAME = 'Custom Bot'; - process.env.AUTOFIX_GIT_USER_EMAIL = 'bot@example.com'; - process.env.AUTOFIX_DRY_RUN = 'true'; - - const config = loadConfig(); - - expect(config.claudeModel).toBe('claude-3-opus'); - expect(config.pollIntervalSeconds).toBe(600); - expect(config.maxErrorsPerPoll).toBe(20); - expect(config.maxPRsPerHour).toBe(10); - expect(config.cooldownSeconds).toBe(7200); - expect(config.gitUserName).toBe('Custom Bot'); - expect(config.gitUserEmail).toBe('bot@example.com'); - expect(config.dryRun).toBe(true); - }); - - it('uses cwd as default working directory', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - const config = loadConfig(); - expect(config.workingDirectory).toBe(process.cwd()); - }); - - it('respects custom working directory', () => { - process.env.ANTHROPIC_API_KEY = 'test-key'; - process.env.AUTOFIX_WORKING_DIR = '/custom/path'; - const config = loadConfig(); - expect(config.workingDirectory).toBe('/custom/path'); - }); - }); -}); diff --git a/src/agent/__tests__/git-ops.test.ts b/src/agent/__tests__/git-ops.test.ts deleted file mode 100644 index 85f523f..0000000 --- a/src/agent/__tests__/git-ops.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { execSync } from 'child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { GitOps } from '../fixer/git-ops.js'; - -describe('GitOps', () => { - let tempDir: string; - let git: GitOps; - - beforeEach(() => { - // Create temp directory with a git repo - tempDir = mkdtempSync(join(tmpdir(), 'git-ops-test-')); - - // Initialize git repo with explicit branch name for consistency - execSync('git init -b master', { cwd: tempDir }); - execSync('git config user.email "test@example.com"', { cwd: tempDir }); - execSync('git config user.name "Test"', { cwd: tempDir }); - - // Create initial commit - writeFileSync(join(tempDir, 'README.md'), '# Test'); - execSync('git add .', { cwd: tempDir }); - execSync('git commit -m "Initial commit"', { cwd: tempDir }); - - git = new GitOps({ - workingDirectory: tempDir, - gitUserName: 'Test Bot', - gitUserEmail: 'bot@example.com', - anthropicApiKey: 'test', - claudeModel: 'test', - pollIntervalSeconds: 300, - maxErrorsPerPoll: 10, - maxPRsPerHour: 5, - cooldownSeconds: 3600, - dryRun: false, - }); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('getCurrentBranch', () => { - it('returns current branch name', () => { - expect(git.getCurrentBranch()).toBe('master'); - }); - - it('returns new branch after checkout', () => { - execSync('git checkout -b feature', { cwd: tempDir }); - expect(git.getCurrentBranch()).toBe('feature'); - }); - }); - - describe('getDefaultBranch', () => { - it('returns master when main does not exist', () => { - expect(git.getDefaultBranch()).toBe('master'); - }); - - it('returns main when main exists', () => { - execSync('git branch -m master main', { cwd: tempDir }); - expect(git.getDefaultBranch()).toBe('main'); - }); - }); - - describe('isClean', () => { - it('returns true when working directory is clean', () => { - expect(git.isClean()).toBe(true); - }); - - it('returns false when there are uncommitted changes', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - expect(git.isClean()).toBe(false); - }); - - it('returns false when there are staged changes', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - execSync('git add new-file.txt', { cwd: tempDir }); - expect(git.isClean()).toBe(false); - }); - }); - - describe('stash/unstash', () => { - it('stash returns false when clean', () => { - expect(git.stash()).toBe(false); - }); - - it('stash returns true and stashes changes', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - expect(git.stash()).toBe(true); - expect(git.isClean()).toBe(true); - }); - - it('unstash restores stashed changes', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - git.stash(); - git.unstash(); - expect(git.isClean()).toBe(false); - }); - }); - - describe('createBranch', () => { - it('creates and checks out a new branch', () => { - // Need a "remote" for this test - create a bare repo - const bareDir = mkdtempSync(join(tmpdir(), 'git-bare-')); - execSync('git init --bare', { cwd: bareDir }); - execSync(`git remote add origin ${bareDir}`, { cwd: tempDir }); - execSync('git push -u origin master', { cwd: tempDir }); - - git.createBranch('feature-branch', 'master'); - - expect(git.getCurrentBranch()).toBe('feature-branch'); - - rmSync(bareDir, { recursive: true, force: true }); - }); - }); - - describe('branchExists', () => { - it('returns true for existing branch', () => { - expect(git.branchExists('master')).toBe(true); - }); - - it('returns false for non-existent branch', () => { - expect(git.branchExists('nonexistent')).toBe(false); - }); - }); - - describe('add and commit', () => { - it('stages and commits files', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - - git.add(['new-file.txt']); - git.commit('Add new file'); - - expect(git.isClean()).toBe(true); - - const log = execSync('git log --oneline -1', { cwd: tempDir, encoding: 'utf-8' }); - expect(log).toContain('Add new file'); - }); - - it('uses configured user for commits', () => { - writeFileSync(join(tempDir, 'new-file.txt'), 'content'); - git.add(['new-file.txt']); - git.commit('Test commit'); - - const log = execSync('git log -1 --format="%an <%ae>"', { cwd: tempDir, encoding: 'utf-8' }); - expect(log.trim()).toBe('Test Bot '); - }); - }); - - describe('checkout', () => { - it('checks out existing branch', () => { - execSync('git branch feature', { cwd: tempDir }); - git.checkout('feature'); - expect(git.getCurrentBranch()).toBe('feature'); - }); - }); - - describe('deleteBranch', () => { - it('deletes existing branch', () => { - execSync('git branch feature', { cwd: tempDir }); - expect(git.branchExists('feature')).toBe(true); - - git.deleteBranch('feature'); - expect(git.branchExists('feature')).toBe(false); - }); - - it('does not throw when deleting non-existent branch', () => { - expect(() => git.deleteBranch('nonexistent')).not.toThrow(); - }); - }); - - describe('getRemoteUrl', () => { - it('returns remote URL when set', () => { - execSync('git remote add origin https://github.com/owner/repo.git', { cwd: tempDir }); - expect(git.getRemoteUrl()).toBe('https://github.com/owner/repo.git'); - }); - }); - - describe('getRepoInfo', () => { - it('parses HTTPS URL', () => { - execSync('git remote add origin https://github.com/myowner/myrepo.git', { cwd: tempDir }); - const info = git.getRepoInfo(); - expect(info).toEqual({ owner: 'myowner', repo: 'myrepo' }); - }); - - it('parses SSH URL', () => { - execSync('git remote add origin git@github.com:myowner/myrepo.git', { cwd: tempDir }); - const info = git.getRepoInfo(); - expect(info).toEqual({ owner: 'myowner', repo: 'myrepo' }); - }); - - it('handles URL without .git suffix', () => { - execSync('git remote add origin https://github.com/myowner/myrepo', { cwd: tempDir }); - const info = git.getRepoInfo(); - expect(info).toEqual({ owner: 'myowner', repo: 'myrepo' }); - }); - - it('returns null for non-GitHub URLs', () => { - execSync('git remote add origin https://gitlab.com/owner/repo.git', { cwd: tempDir }); - const info = git.getRepoInfo(); - expect(info).toBeNull(); - }); - }); -}); diff --git a/src/agent/__tests__/pr-templates.test.ts b/src/agent/__tests__/pr-templates.test.ts deleted file mode 100644 index acc4eee..0000000 --- a/src/agent/__tests__/pr-templates.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - generatePRTitle, - generatePRBody, - generateCommitMessage, -} from '../github/pr-templates.js'; -import type { NormalizedError } from '../watchers/types.js'; -import type { AnalysisResult } from '../analyzer/error-analyzer.js'; -import type { FixResult } from '../fixer/code-fixer.js'; - -describe('pr-templates', () => { - const mockError: NormalizedError = { - timestamp: new Date('2024-01-15T10:00:00Z'), - message: 'TypeError: Cannot read property x of undefined', - stackTrace: 'at foo (src/service.ts:10:5)\n at bar (src/index.ts:20:10)', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: ['TypeError: Cannot read property x of undefined'], - errorType: 'TypeError', - }; - - const mockAnalysis: AnalysisResult = { - canFix: true, - reason: 'This is a null reference that can be fixed with optional chaining', - rootCause: 'The variable x is accessed before being initialized', - suggestedFix: { - description: 'Add null check before accessing property', - files: [ - { - path: 'src/service.ts', - changes: [ - { - type: 'replace', - search: 'obj.x', - replace: 'obj?.x', - }, - ], - }, - ], - }, - confidence: 'high', - testSuggestion: 'Add a test case where obj is undefined', - }; - - const mockFix: FixResult = { - success: true, - branchName: 'autofix/err-abc123', - filesChanged: ['src/service.ts'], - }; - - describe('generatePRTitle', () => { - it('includes service name and description', () => { - const title = generatePRTitle(mockError, mockAnalysis); - - expect(title).toContain('api'); - expect(title).toContain('fix('); - }); - - it('truncates long descriptions', () => { - const longAnalysis: AnalysisResult = { - ...mockAnalysis, - suggestedFix: { - ...mockAnalysis.suggestedFix!, - description: 'A'.repeat(100), - }, - }; - - const title = generatePRTitle(mockError, longAnalysis); - - expect(title.length).toBeLessThan(80); - expect(title).toContain('...'); - }); - - it('handles missing suggestedFix', () => { - const noFixAnalysis: AnalysisResult = { - ...mockAnalysis, - suggestedFix: undefined, - }; - - const title = generatePRTitle(mockError, noFixAnalysis); - - expect(title).toContain('Fix production error'); - }); - }); - - describe('generatePRBody', () => { - it('includes error details section', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('## Auto-Fix: Production Error'); - expect(body).toContain('api'); - expect(body).toContain('production'); - expect(body).toContain('TypeError'); - }); - - it('includes stack trace in details', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Stack Trace'); - expect(body).toContain('
'); - expect(body).toContain('at foo'); - }); - - it('includes root cause analysis', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Root Cause Analysis'); - expect(body).toContain('variable x is accessed before being initialized'); - }); - - it('includes files changed', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Files Changed'); - expect(body).toContain('src/service.ts'); - }); - - it('includes test suggestions when available', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Testing Suggestions'); - expect(body).toContain('test case where obj is undefined'); - }); - - it('includes verification checklist', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Verification Checklist'); - expect(body).toContain('[ ]'); // Unchecked checkboxes - }); - - it('includes fingerprint', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Fingerprint'); - expect(body).toContain('abc123'); - }); - - it('includes confidence level', () => { - const body = generatePRBody({ - error: mockError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).toContain('Confidence'); - expect(body).toContain('high'); - }); - - it('handles error without stack trace', () => { - const noStackError: NormalizedError = { - ...mockError, - stackTrace: undefined, - }; - - const body = generatePRBody({ - error: noStackError, - analysis: mockAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).not.toContain('
'); - expect(body).not.toContain('Stack Trace'); - }); - - it('handles missing test suggestion', () => { - const noTestAnalysis: AnalysisResult = { - ...mockAnalysis, - testSuggestion: undefined, - }; - - const body = generatePRBody({ - error: mockError, - analysis: noTestAnalysis, - fix: mockFix, - fingerprint: 'abc123', - }); - - expect(body).not.toContain('Testing Suggestions'); - }); - }); - - describe('generateCommitMessage', () => { - it('includes service name', () => { - const msg = generateCommitMessage(mockError, mockAnalysis, 'abc123'); - - expect(msg).toContain('fix(api)'); - }); - - it('includes root cause', () => { - const msg = generateCommitMessage(mockError, mockAnalysis, 'abc123'); - - expect(msg).toContain('Root cause:'); - expect(msg).toContain('variable x is accessed'); - }); - - it('includes fingerprint', () => { - const msg = generateCommitMessage(mockError, mockAnalysis, 'abc123'); - - expect(msg).toContain('Fingerprint: abc123'); - }); - - it('includes confidence', () => { - const msg = generateCommitMessage(mockError, mockAnalysis, 'abc123'); - - expect(msg).toContain('Confidence: high'); - }); - - it('uses fix description as title', () => { - const msg = generateCommitMessage(mockError, mockAnalysis, 'abc123'); - - expect(msg).toContain('Add null check before accessing property'); - }); - }); -}); diff --git a/src/agent/__tests__/prompts.test.ts b/src/agent/__tests__/prompts.test.ts deleted file mode 100644 index ffdf0e3..0000000 --- a/src/agent/__tests__/prompts.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - ANALYSIS_SYSTEM_PROMPT, - createAnalysisPrompt, -} from '../analyzer/prompts.js'; - -describe('prompts', () => { - describe('ANALYSIS_SYSTEM_PROMPT', () => { - it('includes JSON schema instruction', () => { - expect(ANALYSIS_SYSTEM_PROMPT).toContain('```json'); - expect(ANALYSIS_SYSTEM_PROMPT).toContain('canFix'); - expect(ANALYSIS_SYSTEM_PROMPT).toContain('suggestedFix'); - }); - - it('includes guidance about when not to fix', () => { - expect(ANALYSIS_SYSTEM_PROMPT).toContain('NOT Fixable'); - expect(ANALYSIS_SYSTEM_PROMPT).toContain('configuration'); - expect(ANALYSIS_SYSTEM_PROMPT).toContain('third-party'); - }); - }); - - describe('createAnalysisPrompt', () => { - it('includes error message', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'TypeError: x is undefined', - serviceName: 'api', - environmentName: 'production', - relevantCode: [], - }); - - expect(prompt).toContain('TypeError: x is undefined'); - }); - - it('includes service and environment info', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Error', - serviceName: 'api-server', - environmentName: 'staging', - relevantCode: [], - }); - - expect(prompt).toContain('api-server'); - expect(prompt).toContain('staging'); - }); - - it('includes stack trace when provided', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Error', - stackTrace: 'at foo (src/service.ts:10:5)', - serviceName: 'api', - environmentName: 'production', - relevantCode: [], - }); - - expect(prompt).toContain('Stack Trace'); - expect(prompt).toContain('at foo (src/service.ts:10:5)'); - }); - - it('omits stack trace section when not provided', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Error', - serviceName: 'api', - environmentName: 'production', - relevantCode: [], - }); - - expect(prompt).not.toContain('Stack Trace'); - }); - - it('includes relevant source code', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Error', - serviceName: 'api', - environmentName: 'production', - relevantCode: [ - { path: 'src/service.ts', content: 'export function foo() { return x; }' }, - { path: 'src/utils.ts', content: 'export const x = undefined;' }, - ], - }); - - expect(prompt).toContain('Relevant Source Code'); - expect(prompt).toContain('src/service.ts'); - expect(prompt).toContain('export function foo()'); - expect(prompt).toContain('src/utils.ts'); - }); - - it('omits source code section when empty', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Error', - serviceName: 'api', - environmentName: 'production', - relevantCode: [], - }); - - expect(prompt).not.toContain('Relevant Source Code'); - }); - - it('formats code blocks properly', () => { - const prompt = createAnalysisPrompt({ - errorMessage: 'Test error', - serviceName: 'api', - environmentName: 'production', - relevantCode: [ - { path: 'src/test.ts', content: 'const x = 1;' }, - ], - }); - - // Should have code blocks with proper formatting - expect(prompt).toContain('```'); - }); - }); -}); diff --git a/src/agent/__tests__/state.test.ts b/src/agent/__tests__/state.test.ts deleted file mode 100644 index 93e4580..0000000 --- a/src/agent/__tests__/state.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { StateManager, type Watch } from '../state.js'; - -describe('StateManager', () => { - // Use a unique prefix for each test to ensure isolation - let tempDirs: string[] = []; - - function createFreshManager(): { manager: StateManager; dir: string } { - const dir = mkdtempSync(join(tmpdir(), `autofix-test-${Date.now()}-${Math.random().toString(36).slice(2)}-`)); - tempDirs.push(dir); - return { manager: new StateManager(dir), dir }; - } - - afterEach(() => { - // Clean up all temp directories - for (const dir of tempDirs) { - try { - rmSync(dir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - tempDirs = []; - }); - - describe('initialization', () => { - it('creates with default state when no file exists', () => { - const { manager } = createFreshManager(); - expect(manager.getWatches()).toEqual([]); - expect(manager.getAllErrors()).toEqual({}); - expect(manager.getLastPollAt()).toBeNull(); - }); - - it('loads existing state from file', () => { - const { manager: manager1, dir } = createFreshManager(); - - // Add some state and save - manager1.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }); - manager1.save(); - - // Create new manager from same directory - const manager2 = new StateManager(dir); - expect(manager2.getWatches()).toHaveLength(1); - expect(manager2.getWatches()[0].serviceName).toBe('api'); - }); - }); - - describe('watches', () => { - it('adds a new watch', () => { - const { manager } = createFreshManager(); - const watch: Watch = { - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }; - - manager.addWatch(watch); - - expect(manager.getWatches()).toHaveLength(1); - expect(manager.getWatches()[0]).toEqual(watch); - }); - - it('updates existing watch', () => { - const { manager } = createFreshManager(); - - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }); - - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: false, - }); - - expect(manager.getWatches()).toHaveLength(1); - expect(manager.getWatches()[0].enabled).toBe(false); - }); - - it('returns only enabled watches', () => { - const { manager } = createFreshManager(); - - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }); - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'worker', - enabled: false, - }); - - const enabled = manager.getEnabledWatches(); - expect(enabled).toHaveLength(1); - expect(enabled[0].serviceName).toBe('api'); - }); - - it('removes a watch', () => { - const { manager } = createFreshManager(); - - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }); - - const removed = manager.removeWatch('proj-1', 'env-1', 'api'); - - expect(removed).toBe(true); - expect(manager.getWatches()).toHaveLength(0); - }); - - it('returns false when removing non-existent watch', () => { - const { manager } = createFreshManager(); - const removed = manager.removeWatch('proj-1', 'env-1', 'api'); - expect(removed).toBe(false); - }); - }); - - describe('errors', () => { - it('tracks a new error', () => { - const { manager } = createFreshManager(); - - const tracked = manager.trackError('fp-unique-1', { - serviceName: 'api', - message: 'Test error message', - }); - - expect(tracked.serviceName).toBe('api'); - expect(tracked.message).toBe('Test error message'); - expect(tracked.occurrenceCount).toBe(1); - expect(tracked.status).toBe('new'); - expect(tracked.firstSeen).toBeDefined(); - expect(tracked.lastSeen).toBeDefined(); - }); - - it('increments occurrence count on duplicate error', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-unique-2', { - serviceName: 'api', - message: 'Test error', - }); - - const tracked = manager.trackError('fp-unique-2', { - serviceName: 'api', - message: 'Test error', - }); - - expect(tracked.occurrenceCount).toBe(2); - }); - - it('truncates long messages', () => { - const { manager } = createFreshManager(); - - const longMessage = 'x'.repeat(1000); - const tracked = manager.trackError('fp-unique-3', { - serviceName: 'api', - message: longMessage, - }); - - expect(tracked.message.length).toBe(500); - }); - - it('updates error status', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-unique-4', { - serviceName: 'api', - message: 'Test error', - }); - - manager.updateErrorStatus('fp-unique-4', 'pr_created', { - prUrl: 'https://github.com/owner/repo/pull/1', - branchName: 'autofix/err-fp-unique-4', - }); - - const error = manager.getError('fp-unique-4'); - expect(error?.status).toBe('pr_created'); - expect(error?.prUrl).toBe('https://github.com/owner/repo/pull/1'); - expect(error?.branchName).toBe('autofix/err-fp-unique-4'); - }); - - it('returns undefined for non-existent error', () => { - const { manager } = createFreshManager(); - expect(manager.getError('non-existent')).toBeUndefined(); - }); - - it('gets all errors', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-a', { serviceName: 'api', message: 'Error 1' }); - manager.trackError('fp-b', { serviceName: 'web', message: 'Error 2' }); - - const all = manager.getAllErrors(); - expect(Object.keys(all)).toHaveLength(2); - }); - }); - - describe('poll tracking', () => { - it('updates last poll timestamp', () => { - const { manager } = createFreshManager(); - - expect(manager.getLastPollAt()).toBeNull(); - - manager.updateLastPoll(); - - const lastPoll = manager.getLastPollAt(); - expect(lastPoll).toBeInstanceOf(Date); - expect(lastPoll!.getTime()).toBeLessThanOrEqual(Date.now()); - }); - }); - - describe('PR rate limiting', () => { - it('allows PRs when under limit', () => { - const { manager } = createFreshManager(); - expect(manager.canCreatePR(5)).toBe(true); - }); - - it('blocks PRs when at limit', () => { - const { manager } = createFreshManager(); - - for (let i = 0; i < 5; i++) { - manager.incrementPRCount(); - } - expect(manager.canCreatePR(5)).toBe(false); - }); - - it('resets count in new hour', () => { - const { manager } = createFreshManager(); - - for (let i = 0; i < 5; i++) { - manager.incrementPRCount(); - } - expect(manager.canCreatePR(5)).toBe(false); - - // Simulate time passing by manipulating state - manager['state'].lastPRCountResetHour = '2020-01-01T00'; - expect(manager.canCreatePR(5)).toBe(true); - }); - }); - - describe('cooldown', () => { - it('returns false when error not tracked', () => { - const { manager } = createFreshManager(); - expect(manager.isInCooldown('unknown', 3600)).toBe(false); - }); - - it('returns false when error not in pr_created status', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cool-1', { serviceName: 'api', message: 'Error' }); - expect(manager.isInCooldown('fp-cool-1', 3600)).toBe(false); - }); - - it('returns true when error is in cooldown', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cool-2', { serviceName: 'api', message: 'Error' }); - manager.updateErrorStatus('fp-cool-2', 'pr_created'); - - expect(manager.isInCooldown('fp-cool-2', 3600)).toBe(true); - }); - - it('returns false when cooldown expired', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cool-3', { serviceName: 'api', message: 'Error' }); - manager.updateErrorStatus('fp-cool-3', 'pr_created'); - - // Set lastSeen to 2 hours ago - const error = manager.getError('fp-cool-3')!; - error.lastSeen = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - - expect(manager.isInCooldown('fp-cool-3', 3600)).toBe(false); - }); - }); - - describe('cleanup', () => { - it('removes old resolved/ignored errors', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cleanup-1', { serviceName: 'api', message: 'Error 1' }); - manager.updateErrorStatus('fp-cleanup-1', 'resolved'); - - // Set to 8 days ago - manager['state'].errors['fp-cleanup-1'].lastSeen = - new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - - manager.cleanup(); - - expect(manager.getError('fp-cleanup-1')).toBeUndefined(); - }); - - it('keeps recent resolved errors', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cleanup-2', { serviceName: 'api', message: 'Error 1' }); - manager.updateErrorStatus('fp-cleanup-2', 'resolved'); - - manager.cleanup(); - - expect(manager.getError('fp-cleanup-2')).toBeDefined(); - }); - - it('keeps errors with other statuses', () => { - const { manager } = createFreshManager(); - - manager.trackError('fp-cleanup-3', { serviceName: 'api', message: 'Error 1' }); - manager.updateErrorStatus('fp-cleanup-3', 'pr_created'); - - // Set to 8 days ago - manager['state'].errors['fp-cleanup-3'].lastSeen = - new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - - manager.cleanup(); - - expect(manager.getError('fp-cleanup-3')).toBeDefined(); - }); - }); - - describe('persistence', () => { - it('saves state to file', () => { - const { manager, dir } = createFreshManager(); - - manager.addWatch({ - projectId: 'proj-1', - environmentId: 'env-1', - serviceName: 'api', - enabled: true, - }); - manager.save(); - - const filePath = join(dir, 'autofix-state.json'); - expect(existsSync(filePath)).toBe(true); - - const content = JSON.parse(readFileSync(filePath, 'utf-8')); - expect(content.watches).toHaveLength(1); - }); - }); -}); diff --git a/src/agent/__tests__/types.test.ts b/src/agent/__tests__/types.test.ts deleted file mode 100644 index 0538d81..0000000 --- a/src/agent/__tests__/types.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - createFingerprint, - isErrorLog, - groupConsecutiveErrors, - ERROR_KEYWORDS, - type NormalizedError, -} from '../watchers/types.js'; - -describe('watchers/types', () => { - describe('createFingerprint', () => { - it('creates consistent fingerprint for same error', () => { - const error: NormalizedError = { - timestamp: new Date(), - message: 'TypeError: Cannot read property x of undefined', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: ['TypeError: Cannot read property x of undefined'], - }; - - const fp1 = createFingerprint(error); - const fp2 = createFingerprint(error); - - expect(fp1).toBe(fp2); - expect(fp1).toHaveLength(16); - }); - - it('creates different fingerprints for different errors', () => { - const error1: NormalizedError = { - timestamp: new Date(), - message: 'TypeError: Cannot read property x of undefined', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const error2: NormalizedError = { - timestamp: new Date(), - message: 'ReferenceError: foo is not defined', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - expect(createFingerprint(error1)).not.toBe(createFingerprint(error2)); - }); - - it('normalizes variable parts of messages', () => { - const error1: NormalizedError = { - timestamp: new Date(), - message: 'Error: User 12345 not found', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const error2: NormalizedError = { - timestamp: new Date(), - message: 'Error: User 67890 not found', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - expect(createFingerprint(error1)).toBe(createFingerprint(error2)); - }); - - it('normalizes UUIDs in messages', () => { - const error1: NormalizedError = { - timestamp: new Date(), - message: 'Error: Record 550e8400-e29b-41d4-a716-446655440000 not found', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const error2: NormalizedError = { - timestamp: new Date(), - message: 'Error: Record a1b2c3d4-e5f6-7890-abcd-ef1234567890 not found', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - expect(createFingerprint(error1)).toBe(createFingerprint(error2)); - }); - - it('normalizes timestamps in messages', () => { - const error1: NormalizedError = { - timestamp: new Date(), - message: 'Error at 2024-01-15T10:30:00Z: Connection failed', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const error2: NormalizedError = { - timestamp: new Date(), - message: 'Error at 2024-02-20T15:45:30Z: Connection failed', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - expect(createFingerprint(error1)).toBe(createFingerprint(error2)); - }); - - it('uses stack trace for fingerprinting when available', () => { - const error1: NormalizedError = { - timestamp: new Date(), - message: 'TypeError: x is undefined', - stackTrace: 'at foo (/app/src/service.ts:10:5)', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const error2: NormalizedError = { - timestamp: new Date(), - message: 'TypeError: x is undefined', - stackTrace: 'at bar (/app/src/other.ts:20:10)', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - expect(createFingerprint(error1)).not.toBe(createFingerprint(error2)); - }); - - it('uses provided errorType', () => { - const error: NormalizedError = { - timestamp: new Date(), - message: 'Something went wrong', - errorType: 'CustomError', - serviceName: 'api', - environmentName: 'production', - projectId: 'proj-1', - rawLines: [], - }; - - const fp = createFingerprint(error); - expect(fp).toBeDefined(); - }); - }); - - describe('isErrorLog', () => { - it('returns true for error severity', () => { - expect(isErrorLog('Normal message', 'error')).toBe(true); - }); - - it('returns true for messages containing error keywords', () => { - for (const keyword of ERROR_KEYWORDS) { - expect(isErrorLog(`Something ${keyword} here`, undefined)).toBe(true); - } - }); - - it('returns false for normal messages', () => { - expect(isErrorLog('Server started on port 3000', undefined)).toBe(false); - expect(isErrorLog('Request completed successfully', 'info')).toBe(false); - }); - - it('is case insensitive', () => { - expect(isErrorLog('ERROR: Something wrong', undefined)).toBe(true); - expect(isErrorLog('Error: Something wrong', undefined)).toBe(true); - expect(isErrorLog('FATAL error occurred', undefined)).toBe(true); - }); - }); - - describe('groupConsecutiveErrors', () => { - it('groups consecutive error logs', () => { - const logs = [ - { timestamp: '2024-01-01T10:00:00Z', message: 'TypeError: x is undefined' }, - { timestamp: '2024-01-01T10:00:01Z', message: ' at foo (file.js:10:5)' }, - { timestamp: '2024-01-01T10:00:02Z', message: ' at bar (file.js:20:10)' }, - { timestamp: '2024-01-01T10:00:03Z', message: 'Request completed' }, - ]; - - const groups = groupConsecutiveErrors(logs); - - expect(groups).toHaveLength(1); - expect(groups[0].lines).toHaveLength(3); - expect(groups[0].timestamp).toBe('2024-01-01T10:00:00Z'); - }); - - it('creates separate groups for non-consecutive errors', () => { - const logs = [ - { timestamp: '2024-01-01T10:00:00Z', message: 'Error: First error' }, - { timestamp: '2024-01-01T10:00:01Z', message: 'Request completed' }, - { timestamp: '2024-01-01T10:00:02Z', message: 'Error: Second error' }, - ]; - - const groups = groupConsecutiveErrors(logs); - - expect(groups).toHaveLength(2); - expect(groups[0].lines[0]).toBe('Error: First error'); - expect(groups[1].lines[0]).toBe('Error: Second error'); - }); - - it('handles empty input', () => { - expect(groupConsecutiveErrors([])).toEqual([]); - }); - - it('includes stack trace lines with at prefix', () => { - const logs = [ - { timestamp: '2024-01-01T10:00:00Z', message: 'Error: Something failed' }, - { timestamp: '2024-01-01T10:00:01Z', message: ' at Object.' }, - { timestamp: '2024-01-01T10:00:02Z', message: ' at Module._compile' }, - ]; - - const groups = groupConsecutiveErrors(logs); - - expect(groups).toHaveLength(1); - expect(groups[0].lines).toHaveLength(3); - }); - - it('includes caret lines for syntax errors', () => { - // Note: Context lines (like ' const x = {') break the grouping - // because they're not detected as error or stack trace lines. - // Only 'at ...' lines or ' ^' caret lines are grouped. - const logs = [ - { timestamp: '2024-01-01T10:00:00Z', message: 'SyntaxError: Unexpected token' }, - { timestamp: '2024-01-01T10:00:01Z', message: ' at Object.' }, - { timestamp: '2024-01-01T10:00:02Z', message: ' ^' }, - ]; - - const groups = groupConsecutiveErrors(logs); - - expect(groups).toHaveLength(1); - expect(groups[0].lines).toHaveLength(3); - }); - - it('respects severity field', () => { - const logs = [ - { timestamp: '2024-01-01T10:00:00Z', message: 'Normal looking message', severity: 'error' as const }, - { timestamp: '2024-01-01T10:00:01Z', message: ' at somewhere' }, - ]; - - const groups = groupConsecutiveErrors(logs); - - expect(groups).toHaveLength(1); - expect(groups[0].lines).toHaveLength(2); - }); - }); -}); diff --git a/src/agent/__tests__/validators.test.ts b/src/agent/__tests__/validators.test.ts deleted file mode 100644 index bf5104d..0000000 --- a/src/agent/__tests__/validators.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { checkFileSyntax } from '../fixer/validators.js'; - -describe('validators', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'validators-test-')); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('checkFileSyntax', () => { - it('returns valid for correct JavaScript', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/valid.js'), 'const x = 1; console.log(x);'); - - const result = checkFileSyntax(tempDir, ['src/valid.js']); - - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it('returns invalid for syntax error', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/invalid.js'), 'const x = {'); - - const result = checkFileSyntax(tempDir, ['src/invalid.js']); - - expect(result.valid).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('returns error for non-existent file', () => { - const result = checkFileSyntax(tempDir, ['nonexistent.js']); - - expect(result.valid).toBe(false); - expect(result.errors[0]).toContain('File not found'); - }); - - it('checks multiple files', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/a.js'), 'const a = 1;'); - writeFileSync(join(tempDir, 'src/b.js'), 'const b = {'); // Invalid - - const result = checkFileSyntax(tempDir, ['src/a.js', 'src/b.js']); - - expect(result.valid).toBe(false); - expect(result.errors.length).toBe(1); - }); - - it('handles non-JS files gracefully', () => { - mkdirSync(join(tempDir, 'src'), { recursive: true }); - writeFileSync(join(tempDir, 'src/data.json'), '{"key": "value"}'); - - const result = checkFileSyntax(tempDir, ['src/data.json']); - - // JSON files should be skipped (no JS check) - expect(result.valid).toBe(true); - }); - }); -}); diff --git a/src/agent/analyzer/code-context.ts b/src/agent/analyzer/code-context.ts deleted file mode 100644 index 4ed6525..0000000 --- a/src/agent/analyzer/code-context.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { readFileSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; - -/** - * Extract relevant source code for an error. - * Uses stack trace to find files and extract context. - */ -export async function extractCodeContext( - workingDirectory: string, - errorMessage: string, - stackTrace?: string -): Promise> { - const files: Array<{ path: string; content: string }> = []; - const seen = new Set(); - - // Extract file paths from stack trace - const filePaths = extractFilePaths(stackTrace || errorMessage); - - for (const filePath of filePaths.slice(0, 5)) { // Limit to 5 files - if (seen.has(filePath)) continue; - seen.add(filePath); - - const resolved = resolveFilePath(workingDirectory, filePath); - if (!resolved) continue; - - try { - const content = readFileSync(resolved.path, 'utf-8'); - // Limit file size - const truncated = content.length > 10000 - ? content.substring(0, 10000) + '\n... (truncated)' - : content; - - files.push({ - path: resolved.relative, - content: truncated, - }); - } catch { - // Skip files we can't read - } - } - - return files; -} - -/** - * Extract file paths from a stack trace. - */ -function extractFilePaths(text: string): string[] { - const paths: string[] = []; - - // Common stack trace patterns - const patterns = [ - // Node.js: at functionName (/path/to/file.js:10:20) - absolute or relative paths - /at\s+(?:\S+\s+)?\(?((?:\/|\.{1,2}\/)[^:)]+):\d+:\d+\)?/g, - // Node.js: at /path/to/file.js:10:20 - /at\s+((?:\/|\.{1,2}\/)[^:]+):\d+:\d+/g, - // TypeScript/JavaScript: at functionName (file.ts:10:20) or (dist/file.js:10:20) - /at\s+(?:\S+\s+)?\(?([^:()\s]+\.[jt]sx?):\d+:\d+\)?/g, - // Python: File "/path/to/file.py", line 10 - /File\s+"([^"]+)",\s+line\s+\d+/g, - // Ruby: /path/to/file.rb:10:in - /(\/[^:]+\.rb):\d+:in/g, - // Go: /path/to/file.go:10 - /(\/[^:]+\.go):\d+/g, - ]; - - for (const pattern of patterns) { - let match; - while ((match = pattern.exec(text)) !== null) { - const path = match[1]; - // Filter out node_modules and system paths - if (!path.includes('node_modules') && - !path.includes('/usr/') && - !path.includes('internal/')) { - paths.push(normalizeFilePath(path)); - } - } - } - - return [...new Set(paths)]; -} - -/** - * Normalize a file path. - */ -function normalizeFilePath(path: string): string { - // Remove leading ./ - let normalized = path.replace(/^\.\//, ''); - - // Convert absolute paths to relative (if they're in the project) - if (normalized.startsWith('/')) { - // Try common src directories - const srcMatch = normalized.match(/\/(src|lib|app|packages)\/.+/); - if (srcMatch) { - normalized = srcMatch[0].substring(1); - } - } - - return normalized; -} - -interface ResolvedPath { - path: string; // Full absolute path - relative: string; // Relative path from working directory -} - -/** - * Resolve a file path relative to the working directory. - * Returns the resolved path and its relative form. - */ -function resolveFilePath(workingDirectory: string, filePath: string): ResolvedPath | null { - // Try as-is - let fullPath = join(workingDirectory, filePath); - if (existsSync(fullPath)) { - return { path: fullPath, relative: filePath }; - } - - // Try common path transformations for TypeScript projects - // These are applied in combination to handle dist/file.js -> src/file.ts - const dirAliases = [ - { from: /^dist\//, to: 'src/' }, - { from: /^build\//, to: 'src/' }, - ]; - - const extAliases = [ - { from: /\.js$/, to: '.ts' }, - { from: /\.js$/, to: '.tsx' }, - ]; - - // Try directory aliases alone - for (const alias of dirAliases) { - const aliasedPath = filePath.replace(alias.from, alias.to); - fullPath = join(workingDirectory, aliasedPath); - if (existsSync(fullPath)) { - return { path: fullPath, relative: aliasedPath }; - } - } - - // Try extension aliases alone - for (const alias of extAliases) { - const aliasedPath = filePath.replace(alias.from, alias.to); - fullPath = join(workingDirectory, aliasedPath); - if (existsSync(fullPath)) { - return { path: fullPath, relative: aliasedPath }; - } - } - - // Try combinations (dist/file.js -> src/file.ts) - for (const dirAlias of dirAliases) { - for (const extAlias of extAliases) { - const aliasedPath = filePath.replace(dirAlias.from, dirAlias.to).replace(extAlias.from, extAlias.to); - fullPath = join(workingDirectory, aliasedPath); - if (existsSync(fullPath)) { - return { path: fullPath, relative: aliasedPath }; - } - } - } - - return null; -} - -/** - * Find related files (tests, types, etc.) for a given source file. - */ -export function findRelatedFiles( - workingDirectory: string, - filePath: string -): string[] { - const related: string[] = []; - const dir = dirname(filePath); - const baseName = filePath.split('/').pop()?.replace(/\.[^.]+$/, '') || ''; - - // Look for test files - const testPatterns = [ - join(dir, `${baseName}.test.ts`), - join(dir, `${baseName}.spec.ts`), - join(dir, '__tests__', `${baseName}.test.ts`), - join('test', filePath.replace(/^src\//, '').replace(/\.ts$/, '.test.ts')), - ]; - - for (const pattern of testPatterns) { - const fullPath = join(workingDirectory, pattern); - if (existsSync(fullPath)) { - related.push(pattern); - } - } - - // Look for type definitions - const typePatterns = [ - join(dir, `${baseName}.types.ts`), - join(dir, 'types.ts'), - join(dir, 'index.d.ts'), - ]; - - for (const pattern of typePatterns) { - const fullPath = join(workingDirectory, pattern); - if (existsSync(fullPath)) { - related.push(pattern); - } - } - - return related; -} diff --git a/src/agent/analyzer/error-analyzer.ts b/src/agent/analyzer/error-analyzer.ts deleted file mode 100644 index a0d291b..0000000 --- a/src/agent/analyzer/error-analyzer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { AutoFixConfig } from '../config.js'; -import type { NormalizedError } from '../watchers/types.js'; -import { ANALYSIS_SYSTEM_PROMPT, createAnalysisPrompt, type AnalysisResponse, type SuggestedFix } from './prompts.js'; -import { extractCodeContext } from './code-context.js'; - -/** - * Result of error analysis. - */ -export interface AnalysisResult { - canFix: boolean; - reason: string; - rootCause: string; - suggestedFix?: SuggestedFix; - confidence: 'low' | 'medium' | 'high'; - testSuggestion?: string; -} - -/** - * Analyzes production errors using Claude API. - */ -export class ErrorAnalyzer { - private readonly config: AutoFixConfig; - - constructor(config: AutoFixConfig) { - this.config = config; - } - - /** - * Analyze an error and determine if it can be fixed. - */ - async analyze(error: NormalizedError): Promise { - // Extract relevant source code - const relevantCode = await extractCodeContext( - this.config.workingDirectory, - error.message, - error.stackTrace - ); - - // Create the prompt - const userPrompt = createAnalysisPrompt({ - errorMessage: error.message, - stackTrace: error.stackTrace, - serviceName: error.serviceName, - environmentName: error.environmentName, - relevantCode, - }); - - // Call Claude API - const response = await this.callClaude(userPrompt); - - return response; - } - - /** - * Call the Claude API for error analysis. - */ - private async callClaude(userPrompt: string): Promise { - // Dynamic import to avoid requiring @anthropic-ai/sdk at module load - const Anthropic = await import('@anthropic-ai/sdk').then((m) => m.default); - - const client = new Anthropic({ - apiKey: this.config.anthropicApiKey, - }); - - const message = await client.messages.create({ - model: this.config.claudeModel, - max_tokens: 4096, - system: ANALYSIS_SYSTEM_PROMPT, - messages: [ - { role: 'user', content: userPrompt }, - ], - }); - - // Extract text content - const textContent = message.content.find((block) => block.type === 'text'); - if (!textContent || textContent.type !== 'text') { - throw new Error('No text response from Claude'); - } - - // Parse JSON response - const responseText = textContent.text; - - // Try to extract JSON from the response - const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```/) || - responseText.match(/\{[\s\S]*\}/); - - if (!jsonMatch) { - throw new Error('No JSON found in Claude response'); - } - - const jsonStr = jsonMatch[1] || jsonMatch[0]; - const parsed = JSON.parse(jsonStr) as AnalysisResponse; - - // Validate response structure - if (typeof parsed.canFix !== 'boolean') { - throw new Error('Invalid response: missing canFix'); - } - - return { - canFix: parsed.canFix, - reason: parsed.reason || 'No reason provided', - rootCause: parsed.rootCause || 'Unknown', - suggestedFix: parsed.suggestedFix, - confidence: parsed.confidence || 'low', - testSuggestion: parsed.testSuggestion, - }; - } -} diff --git a/src/agent/analyzer/prompts.ts b/src/agent/analyzer/prompts.ts deleted file mode 100644 index 7437cbb..0000000 --- a/src/agent/analyzer/prompts.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * System prompt for error analysis. - */ -export const ANALYSIS_SYSTEM_PROMPT = `You are an expert software engineer analyzing production errors. Your task is to: - -1. Understand the root cause of the error -2. Determine if it can be automatically fixed -3. If fixable, provide specific file edits to resolve it - -## Analysis Guidelines - -- Focus on the actual error, not symptoms -- Consider the full stack trace and context -- Look for common patterns (null references, missing imports, type errors) -- Be conservative - only suggest fixes you're confident about - -## When to Mark as NOT Fixable - -- The error requires configuration changes (env vars, secrets) -- The error is in a third-party library -- The fix requires architectural changes -- The error is intermittent/environmental (network, memory) -- You don't have enough context to be confident - -## Response Format - -You must respond with valid JSON matching this schema: - -\`\`\`json -{ - "canFix": boolean, - "reason": "string explaining why it can or cannot be fixed", - "rootCause": "string explaining the root cause", - "suggestedFix": { - "description": "human-readable description of the fix", - "files": [ - { - "path": "relative file path", - "changes": [ - { - "type": "replace", - "search": "exact text to find", - "replace": "replacement text" - } - ] - } - ] - }, - "confidence": "low" | "medium" | "high", - "testSuggestion": "how to verify the fix works" -} -\`\`\` - -If canFix is false, omit suggestedFix.`; - -/** - * User prompt template for error analysis. - */ -export function createAnalysisPrompt(params: { - errorMessage: string; - stackTrace?: string; - serviceName: string; - environmentName: string; - relevantCode: Array<{ path: string; content: string }>; -}): string { - let prompt = `## Production Error - -**Service:** ${params.serviceName} -**Environment:** ${params.environmentName} - -### Error Message -\`\`\` -${params.errorMessage} -\`\`\` -`; - - if (params.stackTrace) { - prompt += ` -### Stack Trace -\`\`\` -${params.stackTrace} -\`\`\` -`; - } - - if (params.relevantCode.length > 0) { - prompt += ` -### Relevant Source Code - -`; - for (const file of params.relevantCode) { - prompt += `**${file.path}:** -\`\`\` -${file.content} -\`\`\` - -`; - } - } - - prompt += ` -Analyze this error and determine if it can be automatically fixed. If so, provide the specific changes needed.`; - - return prompt; -} - -/** - * Schema for the analysis response. - */ -export interface AnalysisResponse { - canFix: boolean; - reason: string; - rootCause: string; - suggestedFix?: SuggestedFix; - confidence: 'low' | 'medium' | 'high'; - testSuggestion?: string; -} - -export interface SuggestedFix { - description: string; - files: FileChange[]; -} - -export interface FileChange { - path: string; - changes: Array<{ - type: 'replace' | 'insert' | 'delete'; - search?: string; - replace?: string; - after?: string; - content?: string; - }>; -} diff --git a/src/agent/autofix-agent.ts b/src/agent/autofix-agent.ts deleted file mode 100644 index d566e9d..0000000 --- a/src/agent/autofix-agent.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { AutoFixConfig } from './config.js'; -import { StateManager, type TrackedError } from './state.js'; -import { LogWatcher, type NormalizedError } from './watchers/log-watcher.js'; -import { RailwayLogWatcher } from './watchers/railway.watcher.js'; -import { CloudRunLogWatcher } from './watchers/cloudrun.watcher.js'; -import { ErrorAnalyzer, type AnalysisResult } from './analyzer/error-analyzer.js'; -import { CodeFixer, type FixResult } from './fixer/code-fixer.js'; -import { PRCreator, type PRResult } from './github/pr-creator.js'; -import { createFingerprint } from './watchers/types.js'; - -/** - * Main orchestrator for the auto-fix agent. - * Coordinates log watching, error analysis, code fixing, and PR creation. - */ -export class AutoFixAgent { - private readonly config: AutoFixConfig; - private readonly state: StateManager; - private readonly watchers: Map; - private readonly providerWatchers: Map; - private readonly analyzer: ErrorAnalyzer; - private readonly fixer: CodeFixer; - private readonly prCreator: PRCreator; - - constructor(config: AutoFixConfig) { - this.config = config; - this.state = new StateManager(config.workingDirectory); - this.watchers = new Map(); - this.providerWatchers = new Map(); - this.analyzer = new ErrorAnalyzer(config); - this.fixer = new CodeFixer(config); - this.prCreator = new PRCreator(config); - } - - /** - * Run a single poll cycle. - * This is called by the GitHub Actions cron job. - */ - async run(): Promise { - console.log('Auto-Fix Agent starting...'); - - const result: RunResult = { - errorsFound: 0, - errorsAnalyzed: 0, - fixesAttempted: 0, - prsCreated: 0, - errors: [], - }; - - try { - // 1. Get enabled watches - const watches = this.state.getEnabledWatches(); - if (watches.length === 0) { - console.log('No enabled watches configured'); - return result; - } - - console.log(`Processing ${watches.length} watches...`); - - // 2. Poll logs for each watch - const allErrors: NormalizedError[] = []; - for (const watch of watches) { - const watcher = await this.getWatcher(watch.projectId); - if (!watcher) { - console.warn(`No watcher available for project ${watch.projectId}`); - continue; - } - - const lastPoll = this.state.getLastPollAt(); - const errors = await watcher.fetchErrors( - watch.environmentId, - watch.serviceName, - { since: lastPoll ?? undefined, limit: this.config.maxErrorsPerPoll } - ); - - console.log(`Found ${errors.length} errors in ${watch.serviceName}`); - allErrors.push(...errors); - } - - result.errorsFound = allErrors.length; - - // 3. Deduplicate and filter errors - const newErrors = this.filterNewErrors(allErrors); - console.log(`${newErrors.length} new/actionable errors after filtering`); - - // 4. Process each error - for (const error of newErrors) { - // Check rate limits - if (!this.state.canCreatePR(this.config.maxPRsPerHour)) { - console.log('PR rate limit reached, stopping for this run'); - break; - } - - // Check cooldown - const fingerprint = createFingerprint(error); - if (this.state.isInCooldown(fingerprint, this.config.cooldownSeconds)) { - console.log(`Error ${fingerprint} is in cooldown, skipping`); - continue; - } - - // Track the error - const tracked = this.state.trackError(fingerprint, { - serviceName: error.serviceName, - message: error.message, - status: 'analyzing', - }); - - try { - // 5. Analyze with Claude - console.log(`Analyzing error: ${error.message.substring(0, 100)}...`); - this.state.updateErrorStatus(fingerprint, 'analyzing'); - this.state.save(); - - const analysis = await this.analyzer.analyze(error); - result.errorsAnalyzed++; - - if (!analysis.canFix) { - console.log(`Error cannot be auto-fixed: ${analysis.reason}`); - this.state.updateErrorStatus(fingerprint, 'ignored'); - continue; - } - - // 6. Apply fix - console.log('Applying fix...'); - this.state.updateErrorStatus(fingerprint, 'fixing'); - this.state.save(); - - if (this.config.dryRun) { - console.log('[DRY RUN] Would apply fix:', JSON.stringify(analysis.suggestedFix, null, 2)); - result.fixesAttempted++; - continue; - } - - const fix = await this.fixer.applyFix(analysis.suggestedFix!, fingerprint); - result.fixesAttempted++; - - if (!fix.success) { - console.error('Failed to apply fix:', fix.error); - this.state.updateErrorStatus(fingerprint, 'new'); // Retry next time - continue; - } - - // 7. Create PR - console.log('Creating PR...'); - const pr = await this.prCreator.createPR({ - branchName: fix.branchName!, - error, - analysis, - fix, - }); - - if (pr.success) { - console.log(`PR created: ${pr.prUrl}`); - result.prsCreated++; - this.state.incrementPRCount(); - this.state.updateErrorStatus(fingerprint, 'pr_created', { - prUrl: pr.prUrl, - branchName: fix.branchName, - }); - } else { - console.error('Failed to create PR:', pr.error); - this.state.updateErrorStatus(fingerprint, 'new'); - } - - } catch (err) { - console.error(`Error processing ${fingerprint}:`, err); - result.errors.push({ - fingerprint, - error: err instanceof Error ? err.message : String(err), - }); - // Reset to new so we can retry - this.state.updateErrorStatus(fingerprint, 'new'); - } - } - - // Update poll timestamp - this.state.updateLastPoll(); - this.state.cleanup(); - this.state.save(); - - console.log(`Run complete: ${result.errorsFound} found, ${result.errorsAnalyzed} analyzed, ${result.prsCreated} PRs created`); - return result; - - } catch (err) { - console.error('Agent run failed:', err); - this.state.save(); - throw err; - } - } - - /** - * Get or create a log watcher for a project, picking the first provider - * whose bindings match (canHandle). Watcher instances are shared across - * projects; the per-project choice is cached. - */ - private async getWatcher(projectId: string): Promise { - const cached = this.watchers.get(projectId); - if (cached) { - return cached; - } - - const factories: Array<[string, () => Promise]> = [ - ['railway', RailwayLogWatcher.create], - ['cloudrun', CloudRunLogWatcher.create], - ]; - for (const [provider, create] of factories) { - let watcher = this.providerWatchers.get(provider) ?? null; - if (watcher === null && !this.providerWatchers.has(provider)) { - watcher = await create(); - this.providerWatchers.set(provider, watcher); - } - if (watcher && await watcher.canHandle(projectId)) { - this.watchers.set(projectId, watcher); - return watcher; - } - } - return null; - } - - /** - * Filter errors to only those we should process. - */ - private filterNewErrors(errors: NormalizedError[]): NormalizedError[] { - const seen = new Set(); - const result: NormalizedError[] = []; - - for (const error of errors) { - const fingerprint = createFingerprint(error); - - // Dedupe within this batch - if (seen.has(fingerprint)) { - continue; - } - seen.add(fingerprint); - - // Check if we've already created a PR for this - const tracked = this.state.getError(fingerprint); - if (tracked) { - if (tracked.status === 'pr_created' || tracked.status === 'ignored' || tracked.status === 'resolved') { - continue; - } - // If analyzing or fixing, skip (in progress) - if (tracked.status === 'analyzing' || tracked.status === 'fixing') { - continue; - } - } - - result.push(error); - } - - return result; - } -} - -export interface RunResult { - errorsFound: number; - errorsAnalyzed: number; - fixesAttempted: number; - prsCreated: number; - errors: Array<{ fingerprint: string; error: string }>; -} diff --git a/src/agent/config.ts b/src/agent/config.ts deleted file mode 100644 index ad8277a..0000000 --- a/src/agent/config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Auto-fix agent configuration loaded from environment variables. - */ -export interface AutoFixConfig { - // Claude API - anthropicApiKey: string; - claudeModel: string; - - // Polling - pollIntervalSeconds: number; - maxErrorsPerPoll: number; - - // Safety limits - maxPRsPerHour: number; - cooldownSeconds: number; - - // Git - workingDirectory: string; - gitUserName: string; - gitUserEmail: string; - - // Dry run mode - dryRun: boolean; -} - -/** - * Load configuration from environment variables with sensible defaults. - */ -export function loadConfig(): AutoFixConfig { - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - if (!anthropicApiKey) { - throw new Error('ANTHROPIC_API_KEY environment variable is required'); - } - - return { - // Claude API - anthropicApiKey, - claudeModel: process.env.AUTOFIX_CLAUDE_MODEL || 'claude-sonnet-4-20250514', - - // Polling - pollIntervalSeconds: parseInt(process.env.AUTOFIX_POLL_INTERVAL || '300', 10), - maxErrorsPerPoll: parseInt(process.env.AUTOFIX_MAX_ERRORS_PER_POLL || '10', 10), - - // Safety limits - maxPRsPerHour: parseInt(process.env.AUTOFIX_MAX_PRS_PER_HOUR || '5', 10), - cooldownSeconds: parseInt(process.env.AUTOFIX_COOLDOWN_SECONDS || '3600', 10), - - // Git - workingDirectory: process.env.AUTOFIX_WORKING_DIR || process.cwd(), - gitUserName: process.env.AUTOFIX_GIT_USER_NAME || 'Auto-Fix Agent', - gitUserEmail: process.env.AUTOFIX_GIT_USER_EMAIL || 'autofix@hypervibe.dev', - - // Dry run mode - dryRun: process.env.AUTOFIX_DRY_RUN === 'true', - }; -} diff --git a/src/agent/fixer/code-fixer.ts b/src/agent/fixer/code-fixer.ts deleted file mode 100644 index 1d0d8a7..0000000 --- a/src/agent/fixer/code-fixer.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { join } from 'path'; -import type { AutoFixConfig } from '../config.js'; -import type { SuggestedFix, FileChange } from '../analyzer/prompts.js'; -import { GitOps } from './git-ops.js'; -import { validateFix } from './validators.js'; - -/** - * Result of applying a fix. - */ -export interface FixResult { - success: boolean; - branchName?: string; - filesChanged?: string[]; - error?: string; - validationErrors?: string[]; -} - -/** - * Applies code fixes suggested by the analyzer. - */ -export class CodeFixer { - private readonly config: AutoFixConfig; - private readonly git: GitOps; - - constructor(config: AutoFixConfig) { - this.config = config; - this.git = new GitOps(config); - } - - /** - * Apply a suggested fix and prepare it for PR. - */ - async applyFix(fix: SuggestedFix, fingerprint: string): Promise { - const branchName = `autofix/err-${fingerprint}`; - const originalBranch = this.git.getCurrentBranch(); - let stashed = false; - - try { - // 1. Check if branch already exists - if (this.git.branchExists(branchName)) { - return { - success: false, - error: `Branch ${branchName} already exists`, - }; - } - - // 2. Stash any uncommitted changes - stashed = this.git.stash(); - - // 3. Create feature branch - this.git.createBranch(branchName); - - // 4. Apply file changes - const filesChanged: string[] = []; - for (const fileChange of fix.files) { - const result = this.applyFileChanges(fileChange); - if (!result.success) { - // Rollback - this.git.checkout(originalBranch); - this.git.deleteBranch(branchName); - if (stashed) this.git.unstash(); - - return { - success: false, - error: result.error, - }; - } - filesChanged.push(fileChange.path); - } - - // 5. Validate changes - const validation = await validateFix(this.config.workingDirectory); - if (!validation.valid) { - // Rollback - this.git.checkout(originalBranch); - this.git.deleteBranch(branchName); - if (stashed) this.git.unstash(); - - return { - success: false, - error: 'Validation failed', - validationErrors: validation.errors, - }; - } - - // 6. Commit changes - this.git.add(filesChanged); - this.git.commit(`fix: ${fix.description}\n\nAuto-generated fix for production error.\nFingerprint: ${fingerprint}`); - - // 7. Push to remote - this.git.push(branchName); - - // 8. Return to original branch - this.git.checkout(originalBranch); - if (stashed) this.git.unstash(); - - return { - success: true, - branchName, - filesChanged, - }; - - } catch (error) { - // Attempt cleanup - try { - this.git.checkout(originalBranch); - this.git.deleteBranch(branchName); - if (stashed) this.git.unstash(); - } catch { - // Ignore cleanup errors - } - - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - /** - * Apply changes to a single file. - */ - private applyFileChanges(fileChange: FileChange): { success: boolean; error?: string } { - const filePath = join(this.config.workingDirectory, fileChange.path); - - // Check if file exists - if (!existsSync(filePath)) { - return { - success: false, - error: `File not found: ${fileChange.path}`, - }; - } - - try { - let content = readFileSync(filePath, 'utf-8'); - - for (const change of fileChange.changes) { - switch (change.type) { - case 'replace': - if (!change.search) { - return { success: false, error: 'Replace change missing search string' }; - } - if (!content.includes(change.search)) { - return { - success: false, - error: `Search string not found in ${fileChange.path}: "${change.search.substring(0, 50)}..."`, - }; - } - content = content.replace(change.search, change.replace || ''); - break; - - case 'insert': - if (!change.after || !change.content) { - return { success: false, error: 'Insert change missing after or content' }; - } - if (!content.includes(change.after)) { - return { - success: false, - error: `Insert anchor not found in ${fileChange.path}: "${change.after.substring(0, 50)}..."`, - }; - } - content = content.replace(change.after, change.after + change.content); - break; - - case 'delete': - if (!change.search) { - return { success: false, error: 'Delete change missing search string' }; - } - if (!content.includes(change.search)) { - return { - success: false, - error: `Delete target not found in ${fileChange.path}: "${change.search.substring(0, 50)}..."`, - }; - } - content = content.replace(change.search, ''); - break; - - default: - return { success: false, error: `Unknown change type: ${(change as { type: string }).type}` }; - } - } - - writeFileSync(filePath, content, 'utf-8'); - return { success: true }; - - } catch (error) { - return { - success: false, - error: `Failed to modify ${fileChange.path}: ${error instanceof Error ? error.message : String(error)}`, - }; - } - } -} diff --git a/src/agent/fixer/git-ops.ts b/src/agent/fixer/git-ops.ts deleted file mode 100644 index 090e8e0..0000000 --- a/src/agent/fixer/git-ops.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { execSync } from 'child_process'; -import type { AutoFixConfig } from '../config.js'; - -/** - * Git operations for the auto-fix agent. - * Uses git CLI directly for reliability. - */ -export class GitOps { - private readonly config: AutoFixConfig; - private readonly cwd: string; - - constructor(config: AutoFixConfig) { - this.config = config; - this.cwd = config.workingDirectory; - } - - /** - * Get the current branch name. - */ - getCurrentBranch(): string { - return this.exec('git rev-parse --abbrev-ref HEAD').trim(); - } - - /** - * Get the default branch (main or master). - */ - getDefaultBranch(): string { - // Try to get from remote refs - try { - const refs = this.exec('git remote show origin 2>/dev/null').trim(); - const match = refs.match(/HEAD branch:\s*(\S+)/); - if (match && match[1] && match[1] !== '(unknown)') { - return match[1]; - } - } catch { - // Fallback - } - - // Check if main or master exists locally - try { - this.exec('git rev-parse --verify main 2>/dev/null'); - return 'main'; - } catch { - // Fall through - } - - try { - this.exec('git rev-parse --verify master 2>/dev/null'); - return 'master'; - } catch { - // Fall through - } - - // As last resort, return current branch - return this.getCurrentBranch(); - } - - /** - * Check if the working directory is clean. - */ - isClean(): boolean { - const status = this.exec('git status --porcelain').trim(); - return status === ''; - } - - /** - * Stash any uncommitted changes (including untracked files). - */ - stash(): boolean { - if (this.isClean()) { - return false; - } - this.exec('git stash --include-untracked'); - return true; - } - - /** - * Pop stashed changes. - */ - unstash(): void { - try { - this.exec('git stash pop'); - } catch { - // Ignore if no stash exists - } - } - - /** - * Create and checkout a new branch. - */ - createBranch(branchName: string, baseBranch?: string): void { - const base = baseBranch || this.getDefaultBranch(); - - // Ensure we have the latest from remote - try { - this.exec(`git fetch origin ${base}`); - } catch { - // Ignore fetch errors - } - - // Create branch from the base - this.exec(`git checkout -b ${branchName} origin/${base}`); - } - - /** - * Checkout an existing branch. - */ - checkout(branchName: string): void { - this.exec(`git checkout ${branchName}`); - } - - /** - * Check if a branch exists. - */ - branchExists(branchName: string): boolean { - try { - this.exec(`git rev-parse --verify ${branchName}`); - return true; - } catch { - return false; - } - } - - /** - * Stage files for commit. - */ - add(files: string[]): void { - for (const file of files) { - this.exec(`git add "${file}"`); - } - } - - /** - * Commit staged changes. - */ - commit(message: string): void { - // Set author info - this.exec(`git config user.name "${this.config.gitUserName}"`); - this.exec(`git config user.email "${this.config.gitUserEmail}"`); - - // Commit with message - const escapedMessage = message.replace(/"/g, '\\"'); - this.exec(`git commit -m "${escapedMessage}"`); - } - - /** - * Push branch to remote. - */ - push(branchName: string): void { - this.exec(`git push -u origin ${branchName}`); - } - - /** - * Delete a local branch. - */ - deleteBranch(branchName: string): void { - try { - this.exec(`git branch -D ${branchName}`); - } catch { - // Ignore if branch doesn't exist - } - } - - /** - * Get the remote URL. - */ - getRemoteUrl(): string { - return this.exec('git remote get-url origin').trim(); - } - - /** - * Extract owner and repo from remote URL. - */ - getRepoInfo(): { owner: string; repo: string } | null { - const url = this.getRemoteUrl(); - - // SSH format: git@github.com:owner/repo.git - const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(\.git)?$/); - if (sshMatch) { - return { owner: sshMatch[1], repo: sshMatch[2] }; - } - - // HTTPS format: https://github.com/owner/repo.git - const httpsMatch = url.match(/github\.com\/([^/]+)\/(.+?)(\.git)?$/); - if (httpsMatch) { - return { owner: httpsMatch[1], repo: httpsMatch[2] }; - } - - return null; - } - - /** - * Execute a git command. - */ - private exec(command: string): string { - return execSync(command, { - cwd: this.cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - } -} diff --git a/src/agent/fixer/validators.ts b/src/agent/fixer/validators.ts deleted file mode 100644 index 6ad93d7..0000000 --- a/src/agent/fixer/validators.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { join } from 'path'; - -/** - * Validation result. - */ -export interface ValidationResult { - valid: boolean; - errors: string[]; - warnings: string[]; -} - -/** - * Run validation checks on the codebase. - */ -export async function validateFix(workingDirectory: string): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - // Detect package manager - const hasYarn = existsSync(join(workingDirectory, 'yarn.lock')); - const hasPnpm = existsSync(join(workingDirectory, 'pnpm-lock.yaml')); - const hasNpm = existsSync(join(workingDirectory, 'package-lock.json')); - - const pm = hasPnpm ? 'pnpm' : hasYarn ? 'yarn' : 'npm'; - - // Check for TypeScript - const hasTsConfig = existsSync(join(workingDirectory, 'tsconfig.json')); - - // Run type check if TypeScript is present - if (hasTsConfig) { - try { - execSync(`${pm} run typecheck 2>&1 || ${pm} exec tsc --noEmit 2>&1`, { - cwd: workingDirectory, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 60000, // 1 minute timeout - }); - } catch (error) { - if (error instanceof Error && 'stdout' in error) { - const output = (error as { stdout?: string }).stdout || ''; - // Extract actual errors - const typeErrors = output.split('\n').filter((line) => - /error TS\d+/.test(line) - ); - if (typeErrors.length > 0) { - errors.push(...typeErrors.slice(0, 5)); - if (typeErrors.length > 5) { - errors.push(`... and ${typeErrors.length - 5} more type errors`); - } - } - } - } - } - - // Check for linting - const hasEslint = existsSync(join(workingDirectory, '.eslintrc')) || - existsSync(join(workingDirectory, '.eslintrc.js')) || - existsSync(join(workingDirectory, '.eslintrc.json')) || - existsSync(join(workingDirectory, 'eslint.config.js')); - - if (hasEslint) { - try { - execSync(`${pm} run lint 2>&1`, { - cwd: workingDirectory, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 60000, - }); - } catch (error) { - if (error instanceof Error && 'stdout' in error) { - const output = (error as { stdout?: string }).stdout || ''; - // Check for actual errors (not just warnings) - const lintErrors = output.split('\n').filter((line) => - /^\s*\d+:\d+\s+error\s/.test(line) - ); - if (lintErrors.length > 0) { - warnings.push(`${lintErrors.length} lint errors found`); - } - } - } - } - - return { - valid: errors.length === 0, - errors, - warnings, - }; -} - -/** - * Run a quick syntax check on specific files. - */ -export function checkFileSyntax( - workingDirectory: string, - files: string[] -): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - for (const file of files) { - const fullPath = join(workingDirectory, file); - - if (!existsSync(fullPath)) { - errors.push(`File not found: ${file}`); - continue; - } - - // TypeScript/JavaScript syntax check - if (/\.[tj]sx?$/.test(file)) { - try { - execSync(`node --check "${fullPath}" 2>&1`, { - cwd: workingDirectory, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 10000, - }); - } catch (error) { - if (error instanceof Error && 'stderr' in error) { - const output = (error as { stderr?: string }).stderr || ''; - errors.push(`Syntax error in ${file}: ${output.split('\n')[0]}`); - } - } - } - } - - return { - valid: errors.length === 0, - errors, - }; -} diff --git a/src/agent/github/pr-creator.ts b/src/agent/github/pr-creator.ts deleted file mode 100644 index 3888a2f..0000000 --- a/src/agent/github/pr-creator.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { execSync } from 'child_process'; -import type { AutoFixConfig } from '../config.js'; -import type { NormalizedError } from '../watchers/types.js'; -import type { AnalysisResult } from '../analyzer/error-analyzer.js'; -import type { FixResult } from '../fixer/code-fixer.js'; -import { generatePRTitle, generatePRBody } from './pr-templates.js'; -import { createFingerprint } from '../watchers/types.js'; - -/** - * Result of PR creation. - */ -export interface PRResult { - success: boolean; - prUrl?: string; - prNumber?: number; - error?: string; -} - -/** - * Parameters for creating a PR. - */ -export interface CreatePRParams { - branchName: string; - error: NormalizedError; - analysis: AnalysisResult; - fix: FixResult; -} - -/** - * Creates GitHub PRs for auto-fixes using the gh CLI. - */ -export class PRCreator { - private readonly config: AutoFixConfig; - - constructor(config: AutoFixConfig) { - this.config = config; - } - - /** - * Create a PR for an auto-fix. - */ - async createPR(params: CreatePRParams): Promise { - const { branchName, error, analysis, fix } = params; - const fingerprint = createFingerprint(error); - - try { - // Check if gh CLI is available - this.checkGhCli(); - - // Generate PR content - const title = generatePRTitle(error, analysis); - const body = generatePRBody({ error, analysis, fix, fingerprint }); - - // Create PR using gh CLI - const result = this.exec( - `gh pr create --head "${branchName}" --title "${this.escapeShell(title)}" --body "${this.escapeShell(body)}"` - ); - - // Parse PR URL from output - const prUrl = result.trim(); - const prMatch = prUrl.match(/\/pull\/(\d+)/); - const prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined; - - // Add labels if possible - if (prNumber) { - try { - this.exec(`gh pr edit ${prNumber} --add-label "auto-fix,production-error"`); - } catch { - // Ignore label errors (labels may not exist) - } - } - - return { - success: true, - prUrl, - prNumber, - }; - - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - - /** - * Check if a PR already exists for this branch. - */ - async prExists(branchName: string): Promise { - try { - const result = this.exec(`gh pr list --head "${branchName}" --json number`); - const prs = JSON.parse(result); - return prs.length > 0; - } catch { - return false; - } - } - - /** - * Get PR URL for a branch. - */ - async getPRUrl(branchName: string): Promise { - try { - const result = this.exec(`gh pr view "${branchName}" --json url -q .url`); - return result.trim() || null; - } catch { - return null; - } - } - - /** - * Add a comment to an existing PR. - */ - async addComment(prNumber: number, comment: string): Promise { - try { - this.exec(`gh pr comment ${prNumber} --body "${this.escapeShell(comment)}"`); - } catch { - // Ignore comment errors - } - } - - /** - * Check if gh CLI is available and authenticated. - */ - private checkGhCli(): void { - try { - this.exec('gh auth status'); - } catch (error) { - throw new Error( - 'GitHub CLI (gh) is not authenticated. Please run "gh auth login" or set GITHUB_TOKEN.' - ); - } - } - - /** - * Execute a command. - */ - private exec(command: string): string { - return execSync(command, { - cwd: this.config.workingDirectory, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Ensure gh uses the token if available - GH_TOKEN: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, - }, - }); - } - - /** - * Escape a string for shell usage. - */ - private escapeShell(str: string): string { - return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\$/g, '\\$') - .replace(/`/g, '\\`'); - } -} diff --git a/src/agent/github/pr-templates.ts b/src/agent/github/pr-templates.ts deleted file mode 100644 index ca6a56d..0000000 --- a/src/agent/github/pr-templates.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { NormalizedError } from '../watchers/types.js'; -import type { AnalysisResult } from '../analyzer/error-analyzer.js'; -import type { FixResult } from '../fixer/code-fixer.js'; - -/** - * Generate a PR title for an auto-fix. - */ -export function generatePRTitle(error: NormalizedError, analysis: AnalysisResult): string { - // Keep it short but descriptive - const errorType = error.errorType || 'error'; - const service = error.serviceName; - - // Truncate description if too long - let description = analysis.suggestedFix?.description || 'Fix production error'; - if (description.length > 50) { - description = description.substring(0, 47) + '...'; - } - - return `fix(${service}): ${description}`; -} - -/** - * Generate the PR body/description. - */ -export function generatePRBody(params: { - error: NormalizedError; - analysis: AnalysisResult; - fix: FixResult; - fingerprint: string; -}): string { - const { error, analysis, fix, fingerprint } = params; - - let body = `## Auto-Fix: Production Error - -This PR was automatically generated by the Auto-Fix Agent after detecting a production error. - -### Error Details - -**Service:** \`${error.serviceName}\` -**Environment:** \`${error.environmentName}\` -**First Detected:** ${error.timestamp.toISOString()} -**Error Type:** ${error.errorType || 'Unknown'} - -\`\`\` -${error.message} -\`\`\` -`; - - if (error.stackTrace) { - body += ` -
-Stack Trace - -\`\`\` -${error.stackTrace} -\`\`\` - -
-`; - } - - body += ` -### Root Cause Analysis - -${analysis.rootCause} - -### Fix Description - -${analysis.suggestedFix?.description || 'See file changes below.'} - -**Confidence:** ${analysis.confidence} - -### Files Changed - -${fix.filesChanged?.map((f) => `- \`${f}\``).join('\n') || 'No files changed'} -`; - - if (analysis.testSuggestion) { - body += ` -### Testing Suggestions - -${analysis.testSuggestion} -`; - } - - body += ` -### Verification Checklist - -- [ ] Review the code changes -- [ ] Verify the fix addresses the root cause -- [ ] Run tests locally -- [ ] Deploy to staging and verify error is resolved -- [ ] Monitor production after merge - ---- - -**Fingerprint:** \`${fingerprint}\` - -Generated by [Hypervibe Auto-Fix Agent](https://github.com/hypervibe/hypervibe) -`; - - return body; -} - -/** - * Generate commit message for the fix. - */ -export function generateCommitMessage( - error: NormalizedError, - analysis: AnalysisResult, - fingerprint: string -): string { - const title = analysis.suggestedFix?.description || 'Fix production error'; - const service = error.serviceName; - - return `fix(${service}): ${title} - -Root cause: ${analysis.rootCause} - -Auto-generated fix for production error. -Fingerprint: ${fingerprint} -Error type: ${error.errorType || 'Unknown'} -Confidence: ${analysis.confidence} -`; -} diff --git a/src/agent/index.ts b/src/agent/index.ts deleted file mode 100644 index 2b21069..0000000 --- a/src/agent/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -/** - * Auto-Fix Agent Entry Point - * - * This script runs a single poll cycle of the auto-fix agent. - * It's designed to be run by GitHub Actions on a cron schedule. - * - * Usage: - * npm run autofix - * - * Required environment variables: - * - ANTHROPIC_API_KEY: Claude API key for error analysis - * - RAILWAY_API_TOKEN: Railway API token (if watching Railway services) - * - GCP_SERVICE_ACCOUNT_JSON: GCP service account JSON (if watching Cloud Run services) - * - GITHUB_TOKEN: GitHub token for PR creation (auto-provided in Actions) - * - * Optional environment variables: - * - AUTOFIX_DRY_RUN: Set to 'true' to analyze without creating PRs - * - AUTOFIX_CLAUDE_MODEL: Claude model to use (default: claude-sonnet-4-20250514) - * - AUTOFIX_MAX_PRS_PER_HOUR: Rate limit for PR creation (default: 5) - * - AUTOFIX_COOLDOWN_SECONDS: Cooldown after PR creation (default: 3600) - */ - -import { loadConfig } from './config.js'; -import { AutoFixAgent } from './autofix-agent.js'; - -async function main() { - try { - const config = loadConfig(); - - if (config.dryRun) { - console.log('Running in DRY RUN mode - no PRs will be created'); - } - - const agent = new AutoFixAgent(config); - const result = await agent.run(); - - // Exit with error if there were processing errors - if (result.errors.length > 0) { - console.error('Some errors failed to process:'); - for (const err of result.errors) { - console.error(` ${err.fingerprint}: ${err.error}`); - } - process.exit(1); - } - - // Output summary for GitHub Actions - console.log('\n--- Summary ---'); - console.log(`Errors found: ${result.errorsFound}`); - console.log(`Errors analyzed: ${result.errorsAnalyzed}`); - console.log(`Fixes attempted: ${result.fixesAttempted}`); - console.log(`PRs created: ${result.prsCreated}`); - - } catch (error) { - console.error('Auto-fix agent failed:', error); - process.exit(1); - } -} - -main(); diff --git a/src/agent/state.ts b/src/agent/state.ts deleted file mode 100644 index d5a689e..0000000 --- a/src/agent/state.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { join } from 'path'; - -/** - * Watch configuration for a service. - */ -export interface Watch { - projectId: string; - environmentId: string; - serviceName: string; - enabled: boolean; -} - -/** - * Status of a tracked error. - */ -export type ErrorStatus = - | 'new' // Just detected - | 'analyzing' // Being analyzed by Claude - | 'fixing' // Fix is being prepared - | 'pr_created' // PR has been created - | 'ignored' // User marked as ignored - | 'resolved'; // Error stopped occurring after fix - -/** - * Tracked error with deduplication and status info. - */ -export interface TrackedError { - /** First time this error was seen */ - firstSeen: string; - /** Last time this error was seen */ - lastSeen: string; - /** Number of occurrences */ - occurrenceCount: number; - /** Current status */ - status: ErrorStatus; - /** GitHub PR URL if created */ - prUrl?: string; - /** Branch name if created */ - branchName?: string; - /** Service where the error occurred */ - serviceName: string; - /** Error message (truncated for storage) */ - message: string; -} - -/** - * Persisted state for the auto-fix agent. - * Stored as JSON in the repo for transparency. - */ -export interface AutoFixState { - /** Active watches */ - watches: Watch[]; - /** Tracked errors by fingerprint */ - errors: Record; - /** Last successful poll timestamp */ - lastPollAt: string | null; - /** PRs created in the current hour (for rate limiting) */ - prsCreatedThisHour: number; - /** Hour when prsCreatedThisHour was last reset */ - lastPRCountResetHour: string | null; -} - -/** - * Create a fresh default state (avoids shared references). - */ -function createDefaultState(): AutoFixState { - return { - watches: [], - errors: {}, - lastPollAt: null, - prsCreatedThisHour: 0, - lastPRCountResetHour: null, - }; -} - -const STATE_FILE = 'autofix-state.json'; - -/** - * Manages the persistent state for the auto-fix agent. - */ -export class StateManager { - private state: AutoFixState; - private readonly filePath: string; - - constructor(workingDirectory: string = process.cwd()) { - this.filePath = join(workingDirectory, STATE_FILE); - this.state = this.load(); - } - - /** - * Load state from disk, or create default state. - */ - private load(): AutoFixState { - if (!existsSync(this.filePath)) { - return createDefaultState(); - } - - try { - const content = readFileSync(this.filePath, 'utf-8'); - const parsed = JSON.parse(content) as AutoFixState; - // Merge with defaults to handle schema evolution - return { ...createDefaultState(), ...parsed }; - } catch { - console.error('Failed to load autofix state, using defaults'); - return createDefaultState(); - } - } - - /** - * Save current state to disk. - */ - save(): void { - writeFileSync(this.filePath, JSON.stringify(this.state, null, 2) + '\n'); - } - - /** - * Get all watches. - */ - getWatches(): Watch[] { - return this.state.watches; - } - - /** - * Get enabled watches only. - */ - getEnabledWatches(): Watch[] { - return this.state.watches.filter((w) => w.enabled); - } - - /** - * Add or update a watch. - */ - addWatch(watch: Watch): void { - const existing = this.state.watches.findIndex( - (w) => w.projectId === watch.projectId && - w.environmentId === watch.environmentId && - w.serviceName === watch.serviceName - ); - - if (existing >= 0) { - this.state.watches[existing] = watch; - } else { - this.state.watches.push(watch); - } - } - - /** - * Remove a watch. - */ - removeWatch(projectId: string, environmentId: string, serviceName: string): boolean { - const initialLength = this.state.watches.length; - this.state.watches = this.state.watches.filter( - (w) => !(w.projectId === projectId && - w.environmentId === environmentId && - w.serviceName === serviceName) - ); - return this.state.watches.length < initialLength; - } - - /** - * Get a tracked error by fingerprint. - */ - getError(fingerprint: string): TrackedError | undefined { - return this.state.errors[fingerprint]; - } - - /** - * Get all tracked errors. - */ - getAllErrors(): Record { - return this.state.errors; - } - - /** - * Track a new error or update an existing one. - */ - trackError(fingerprint: string, update: Partial & { serviceName: string; message: string }): TrackedError { - const now = new Date().toISOString(); - const existing = this.state.errors[fingerprint]; - - if (existing) { - this.state.errors[fingerprint] = { - ...existing, - ...update, - lastSeen: now, - occurrenceCount: existing.occurrenceCount + 1, - }; - } else { - const { serviceName, message, ...rest } = update; - this.state.errors[fingerprint] = { - firstSeen: now, - lastSeen: now, - occurrenceCount: 1, - status: 'new', - serviceName, - message: message.substring(0, 500), - ...rest, - }; - } - - return this.state.errors[fingerprint]; - } - - /** - * Update error status. - */ - updateErrorStatus(fingerprint: string, status: ErrorStatus, extra?: { prUrl?: string; branchName?: string }): void { - const error = this.state.errors[fingerprint]; - if (error) { - error.status = status; - if (extra?.prUrl) error.prUrl = extra.prUrl; - if (extra?.branchName) error.branchName = extra.branchName; - } - } - - /** - * Update last poll timestamp. - */ - updateLastPoll(): void { - this.state.lastPollAt = new Date().toISOString(); - } - - /** - * Get last poll timestamp. - */ - getLastPollAt(): Date | null { - return this.state.lastPollAt ? new Date(this.state.lastPollAt) : null; - } - - /** - * Check if we can create more PRs this hour. - */ - canCreatePR(maxPRsPerHour: number): boolean { - this.resetPRCountIfNewHour(); - return this.state.prsCreatedThisHour < maxPRsPerHour; - } - - /** - * Increment PR count for rate limiting. - */ - incrementPRCount(): void { - this.resetPRCountIfNewHour(); - this.state.prsCreatedThisHour++; - } - - /** - * Reset PR count if we're in a new hour. - */ - private resetPRCountIfNewHour(): void { - const currentHour = new Date().toISOString().substring(0, 13); // YYYY-MM-DDTHH - if (this.state.lastPRCountResetHour !== currentHour) { - this.state.prsCreatedThisHour = 0; - this.state.lastPRCountResetHour = currentHour; - } - } - - /** - * Check if an error is in cooldown (recently had a PR created). - */ - isInCooldown(fingerprint: string, cooldownSeconds: number): boolean { - const error = this.state.errors[fingerprint]; - if (!error || error.status !== 'pr_created') { - return false; - } - - const lastSeen = new Date(error.lastSeen); - const cooldownEnd = new Date(lastSeen.getTime() + cooldownSeconds * 1000); - return new Date() < cooldownEnd; - } - - /** - * Clean up old resolved/ignored errors (older than 7 days). - */ - cleanup(): void { - const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - for (const [fingerprint, error] of Object.entries(this.state.errors)) { - if ( - (error.status === 'resolved' || error.status === 'ignored') && - new Date(error.lastSeen) < cutoff - ) { - delete this.state.errors[fingerprint]; - } - } - } -} diff --git a/src/agent/watchers/cloudrun.watcher.ts b/src/agent/watchers/cloudrun.watcher.ts deleted file mode 100644 index 744c63c..0000000 --- a/src/agent/watchers/cloudrun.watcher.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { LogWatcher, type NormalizedError, type FetchErrorsOptions } from './log-watcher.js'; -import { groupConsecutiveErrors, isErrorLog, normalizeErrorGroups } from './types.js'; -import { CloudRunAdapter } from '../../adapters/providers/gcp/cloudrun.adapter.js'; -import { ConnectionRepository } from '../../adapters/db/repositories/connection.repository.js'; -import { EnvironmentRepository } from '../../adapters/db/repositories/environment.repository.js'; -import { getSecretStore } from '../../adapters/secrets/secret-store.js'; -import { parseHostingBindings } from '../../domain/ports/hosting.port.js'; - -/** - * Log watcher for Cloud Run deployments. - * Delegates to CloudRunAdapter.getLogs, which resolves service vs - * scheduled-job bindings and returns severity-normalized entries. - */ -export class CloudRunLogWatcher extends LogWatcher { - readonly provider = 'cloudrun'; - - private adapter: CloudRunAdapter; - private envRepo: EnvironmentRepository; - - private constructor(adapter: CloudRunAdapter) { - super(); - this.adapter = adapter; - this.envRepo = new EnvironmentRepository(); - } - - /** - * Create a Cloud Run log watcher if GCP credentials are available. - */ - static async create(): Promise { - const connectionRepo = new ConnectionRepository(); - const connection = connectionRepo.findByProvider('cloudrun'); - - if (!connection) { - console.log('No Cloud Run connection found'); - return null; - } - - try { - const secretStore = getSecretStore(); - const credentials = secretStore.decryptObject>(connection.credentialsEncrypted); - - const adapter = new CloudRunAdapter(); - await adapter.connect(credentials); - - // Verify connection - const verified = await adapter.verify(); - if (!verified.success) { - console.error('Cloud Run connection verification failed:', verified.error); - return null; - } - - return new CloudRunLogWatcher(adapter); - } catch (error) { - console.error('Failed to create Cloud Run log watcher:', error); - return null; - } - } - - async canHandle(projectId: string): Promise { - // Check if any environment in this project has Cloud Run bindings - const envs = this.envRepo.findByProjectId(projectId); - return envs.some((env) => { - const bindings = parseHostingBindings(env); - return bindings.provider === 'cloudrun' && !!bindings.projectId; - }); - } - - async fetchErrors( - environmentId: string, - serviceName: string, - options?: FetchErrorsOptions - ): Promise { - const env = this.envRepo.findById(environmentId); - if (!env) { - console.warn(`Environment not found: ${environmentId}`); - return []; - } - - const bindings = parseHostingBindings(env); - if (bindings.provider !== 'cloudrun') { - console.warn('Environment not bound to Cloud Run'); - return []; - } - - try { - // Fetch more than requested so grouping/filtering has material to work with - const limit = options?.limit ? options.limit * 10 : 500; - const logs = await this.adapter.getLogs(env, serviceName, { - limit, - since: options?.since, - errorsOnly: true, - }); - - const errorGroups = groupConsecutiveErrors( - logs - .map((log) => ({ - timestamp: log.timestamp.toISOString(), - message: log.message, - severity: log.severity, - })) - .filter((log) => isErrorLog(log.message, log.severity)) - ); - - const errors = normalizeErrorGroups(errorGroups, { - serviceName, - environmentName: env.name, - projectId: env.projectId, - }); - - const maxErrors = options?.limit ?? 10; - return errors.slice(0, maxErrors); - } catch (error) { - console.error('Failed to fetch Cloud Run logs:', error); - return []; - } - } -} diff --git a/src/agent/watchers/log-watcher.ts b/src/agent/watchers/log-watcher.ts deleted file mode 100644 index 98c38cc..0000000 --- a/src/agent/watchers/log-watcher.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NormalizedError, FetchErrorsOptions } from './types.js'; - -export type { NormalizedError, FetchErrorsOptions }; - -/** - * Abstract interface for log watchers. - * Each hosting platform implements this to fetch and normalize error logs. - */ -export abstract class LogWatcher { - abstract readonly provider: string; - - /** - * Fetch errors from a service's logs. - * - * @param environmentId - The environment ID (platform-specific) - * @param serviceName - The name of the service to fetch logs from - * @param options - Filtering options - * @returns Normalized errors suitable for analysis - */ - abstract fetchErrors( - environmentId: string, - serviceName: string, - options?: FetchErrorsOptions - ): Promise; - - /** - * Check if this watcher can handle the given project. - */ - abstract canHandle(projectId: string): Promise; -} diff --git a/src/agent/watchers/railway.watcher.ts b/src/agent/watchers/railway.watcher.ts deleted file mode 100644 index 13e7d7d..0000000 --- a/src/agent/watchers/railway.watcher.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { LogWatcher, type NormalizedError, type FetchErrorsOptions } from './log-watcher.js'; -import { groupConsecutiveErrors, isErrorLog, normalizeErrorGroups } from './types.js'; -import { RailwayAdapter, type RailwayCredentials, type RailwayLogEntry } from '../../adapters/providers/railway/railway.adapter.js'; -import { ConnectionRepository } from '../../adapters/db/repositories/connection.repository.js'; -import { EnvironmentRepository } from '../../adapters/db/repositories/environment.repository.js'; -import { getSecretStore } from '../../adapters/secrets/secret-store.js'; -import { parseHostingBindings } from '../../domain/ports/hosting.port.js'; - -/** - * Log watcher for Railway deployments. - * Uses the existing RailwayAdapter to fetch deployment logs. - */ -export class RailwayLogWatcher extends LogWatcher { - readonly provider = 'railway'; - - private adapter: RailwayAdapter; - private envRepo: EnvironmentRepository; - - private constructor(adapter: RailwayAdapter) { - super(); - this.adapter = adapter; - this.envRepo = new EnvironmentRepository(); - } - - /** - * Create a Railway log watcher if Railway credentials are available. - */ - static async create(): Promise { - const connectionRepo = new ConnectionRepository(); - const connection = connectionRepo.findByProvider('railway'); - - if (!connection) { - console.log('No Railway connection found'); - return null; - } - - try { - const secretStore = getSecretStore(); - const credentials = secretStore.decryptObject(connection.credentialsEncrypted); - - const adapter = new RailwayAdapter(); - await adapter.connect(credentials); - - // Verify connection - const verified = await adapter.verify(); - if (!verified.success) { - console.error('Railway connection verification failed:', verified.error); - return null; - } - - return new RailwayLogWatcher(adapter); - } catch (error) { - console.error('Failed to create Railway log watcher:', error); - return null; - } - } - - async canHandle(projectId: string): Promise { - // Check if any environment in this project has Railway bindings - const envs = this.envRepo.findByProjectId(projectId); - return envs.some((env) => { - const bindings = parseHostingBindings(env); - return bindings.provider === 'railway' && !!bindings.projectId; - }); - } - - async fetchErrors( - environmentId: string, - serviceName: string, - options?: FetchErrorsOptions - ): Promise { - // Find the environment - const env = this.envRepo.findById(environmentId); - if (!env) { - console.warn(`Environment not found: ${environmentId}`); - return []; - } - - const bindings = parseHostingBindings(env); - - if (bindings.provider !== 'railway' || !bindings.projectId || !bindings.environmentId) { - console.warn('Environment not bound to Railway'); - return []; - } - - const serviceBinding = bindings.services?.[serviceName]; - if (!serviceBinding) { - console.warn(`Service ${serviceName} not found in Railway bindings`); - return []; - } - - try { - // Get latest deployment - const deployments = await this.adapter.getDeployments( - bindings.projectId, - bindings.environmentId, - serviceBinding.serviceId, - 1 - ); - - if (deployments.length === 0) { - console.log('No deployments found'); - return []; - } - - const deployment = deployments[0]; - - // Fetch logs - const limit = options?.limit ? options.limit * 10 : 500; // Fetch more to filter - const logs = await this.adapter.getDeploymentLogs(deployment.id, limit); - - // Filter by timestamp if specified - let filteredLogs = logs; - if (options?.since) { - filteredLogs = logs.filter((log) => new Date(log.timestamp) > options.since!); - } - - // Group consecutive error logs - const errorGroups = groupConsecutiveErrors( - filteredLogs.filter((log) => isErrorLog(log.message, log.severity)) - ); - - // Convert to normalized errors - const errors: NormalizedError[] = normalizeErrorGroups(errorGroups, { - serviceName, - environmentName: env.name, - projectId: env.projectId, - }); - - // Limit results - const maxErrors = options?.limit ?? 10; - return errors.slice(0, maxErrors); - - } catch (error) { - console.error('Failed to fetch Railway logs:', error); - return []; - } - } -} diff --git a/src/agent/watchers/types.ts b/src/agent/watchers/types.ts deleted file mode 100644 index 0c4f129..0000000 --- a/src/agent/watchers/types.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { createHash } from 'crypto'; - -/** - * A normalized error from any hosting platform. - */ -export interface NormalizedError { - /** Timestamp when the error occurred */ - timestamp: Date; - /** The error message */ - message: string; - /** Stack trace if available */ - stackTrace?: string; - /** Service name where the error occurred */ - serviceName: string; - /** Environment name */ - environmentName: string; - /** Project identifier */ - projectId: string; - /** Original raw log lines */ - rawLines: string[]; - /** Error type if detectable (e.g., 'TypeError', 'ConnectionError') */ - errorType?: string; -} - -/** - * Options for fetching errors. - */ -export interface FetchErrorsOptions { - /** Maximum number of errors to return */ - limit?: number; - /** Only return errors after this timestamp */ - since?: Date; -} - -/** - * Create a fingerprint for deduplication. - * Uses error type + first line of stack trace (or message) to group similar errors. - */ -export function createFingerprint(error: NormalizedError): string { - // Extract error type from message if not provided - const errorType = error.errorType || extractErrorType(error.message); - - // Get stable part of stack trace (first frame) - const stackFrame = error.stackTrace?.split('\n')[0]?.trim() || ''; - - // Normalize message (remove variable parts like IDs, timestamps) - const normalizedMessage = normalizeMessage(error.message); - - // Combine and hash - const input = `${errorType}:${stackFrame}:${normalizedMessage}`; - return createHash('sha256').update(input).digest('hex').substring(0, 16); -} - -/** - * Detect the error type from a log message, or undefined when no known - * pattern matches. e.g., "TypeError: Cannot read property..." -> "TypeError" - */ -export function detectErrorType(message: string): string | undefined { - // Common patterns - const patterns = [ - /^(\w+Error):/, // TypeError:, ReferenceError:, etc. - /^(\w+Exception):/, // NullPointerException:, etc. - /^Error: (\w+):/, // Error: ENOENT:, etc. - /^Uncaught (\w+Error)/, // Uncaught TypeError - /^\[(\w+Error)\]/, // [DatabaseError] - ]; - - for (const pattern of patterns) { - const match = message.match(pattern); - if (match) { - return match[1]; - } - } - - return undefined; -} - -function extractErrorType(message: string): string { - return detectErrorType(message) ?? 'UnknownError'; -} - -/** - * Normalize a message by removing variable parts. - */ -function normalizeMessage(message: string): string { - return message - // Remove UUIDs - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '') - // Remove hex IDs - .replace(/\b[0-9a-f]{24,}\b/gi, '') - // Remove timestamps - .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.\d]*Z?/g, '') - // Remove numbers that look like IDs or counts - .replace(/\b\d{5,}\b/g, '') - // Remove IP addresses - .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '') - // Remove file paths with line numbers - .replace(/:\d+:\d+/g, ':') - // Collapse whitespace - .replace(/\s+/g, ' ') - .trim() - // Take first 200 chars - .substring(0, 200); -} - -/** - * Keywords that indicate an error log. - */ -export const ERROR_KEYWORDS = [ - 'error', - 'exception', - 'failed', - 'crash', - 'fatal', - 'panic', - 'unhandled', - 'uncaught', -]; - -/** - * Check if a log message indicates an error. - */ -export function isErrorLog(message: string, severity?: string): boolean { - if (severity === 'error') { - return true; - } - - const lower = message.toLowerCase(); - return ERROR_KEYWORDS.some((keyword) => lower.includes(keyword)); -} - -/** - * Convert grouped error log lines into NormalizedError records. - * Shared by every provider watcher so error shape stays uniform. - */ -export function normalizeErrorGroups( - errorGroups: Array<{ timestamp: string; lines: string[] }>, - context: { serviceName: string; environmentName: string; projectId: string } -): NormalizedError[] { - return errorGroups.map((group) => { - const firstLine = group.lines[0]; - const stackLines = group.lines.slice(1).filter((l) => /^\s+at\s/.test(l)); - - return { - timestamp: new Date(group.timestamp), - message: firstLine, - stackTrace: stackLines.length > 0 ? stackLines.join('\n') : undefined, - serviceName: context.serviceName, - environmentName: context.environmentName, - projectId: context.projectId, - rawLines: group.lines, - errorType: detectErrorType(firstLine), - }; - }); -} - -/** - * Group consecutive error logs into a single error (for stack traces). - */ -export function groupConsecutiveErrors( - logs: Array<{ timestamp: string; message: string; severity?: string }> -): Array<{ timestamp: string; lines: string[] }> { - const groups: Array<{ timestamp: string; lines: string[] }> = []; - let currentGroup: { timestamp: string; lines: string[] } | null = null; - - for (const log of logs) { - const isError = isErrorLog(log.message, log.severity); - const isStackTraceLine = /^\s+at\s/.test(log.message) || /^\s+\^/.test(log.message); - - if (isError || (currentGroup && isStackTraceLine)) { - if (!currentGroup) { - currentGroup = { timestamp: log.timestamp, lines: [] }; - } - currentGroup.lines.push(log.message); - } else if (currentGroup) { - groups.push(currentGroup); - currentGroup = null; - } - } - - if (currentGroup) { - groups.push(currentGroup); - } - - return groups; -} diff --git a/src/domain/spec/__tests__/github.schema.test.ts b/src/domain/spec/__tests__/github.schema.test.ts index 15c7432..f33ba99 100644 --- a/src/domain/spec/__tests__/github.schema.test.ts +++ b/src/domain/spec/__tests__/github.schema.test.ts @@ -111,6 +111,21 @@ describe('github desired state', () => { }).github).toBeUndefined(); }); + it('rejects legacy runtime autofix intent with migration guidance', () => { + const legacy = baseSpec({}); + legacy.environments.production = { + ...legacy.environments.production, + autofix: { enabled: true, services: ['web'] }, + } as typeof legacy.environments.production; + + const result = projectSpecSchema.safeParse(legacy); + expect(result.success).toBe(false); + const messages = result.success ? [] : result.error.issues.map((issue) => issue.message); + expect(messages).toContain( + 'environments.*.autofix has been removed. Use hv_errors action="list" or action="summary" for live runtime errors; use github.actions. kind="autofix" to repair failed GitHub workflow checks.' + ); + }); + it('canonicalizes legacy collaboration on the next explicit spec update', () => { const canonical = projectSpecSchema.parse(canonicalizeLegacyGitHubSpec({ version: 1, diff --git a/src/domain/spec/spec.schema.ts b/src/domain/spec/spec.schema.ts index c91cc0b..dd39e16 100644 --- a/src/domain/spec/spec.schema.ts +++ b/src/domain/spec/spec.schema.ts @@ -478,13 +478,16 @@ export const environmentSpecSchema = z.object({ z.string().regex(/^[a-z][a-z0-9-]{0,60}$/, 'storage names: lowercase alphanumeric and dashes, starting with a letter'), storageSpecSchema ).optional(), - /** Autofix agent log watches, synced on hv_apply. */ - autofix: z.object({ - enabled: z.boolean(), - /** Services to watch (default: all services in this environment). */ - services: z.array(z.string().min(1)).optional(), - }).optional(), + /** Kept only to produce an actionable migration error for old specs. */ + autofix: z.unknown().optional(), }).superRefine((environment, ctx) => { + if (environment.autofix !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'environments.*.autofix has been removed. Use hv_errors action="list" or action="summary" for live runtime errors; use github.actions. kind="autofix" to repair failed GitHub workflow checks.', + path: ['autofix'], + }); + } if (environment.domainRegistration && !environment.domain) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/tools/__tests__/hv-observability.tools.test.ts b/src/tools/__tests__/hv-observability.tools.test.ts index 687ac80..a57146d 100644 --- a/src/tools/__tests__/hv-observability.tools.test.ts +++ b/src/tools/__tests__/hv-observability.tools.test.ts @@ -81,20 +81,31 @@ describe('hv_logs', () => { }); describe('hv_errors', () => { - it('validates fingerprint for action=ignore', async () => { - const t = await makeClient(); - const result = await t.call('hv_errors', { action: 'ignore' }); - expect(result.ok).toBe(false); - expect(result.error.code).toBe('VALIDATION'); - await t.close(); - }); - - it('lists tracked autofix errors', async () => { + it.each([ + ['list', { totalFound: 0, errors: [] }], + ['summary', { + summary: { totalServices: 0, totalErrors: 0, failedDeployments: 0, healthyServices: 0 }, + services: [], + }], + ] as const)('keeps provider-neutral runtime error %s visibility', async (action, expected) => { + const project = new ProjectRepository().create({ name: `errors-${action}-app` }); + new EnvironmentRepository().create({ + projectId: project.id, + name: 'production', + platformBindings: { provider: 'railway', services: {} }, + }); const t = await makeClient(); - const result = await t.call('hv_errors', { action: 'tracked' }); + const result = await t.call('hv_errors', { + project: project.name, + env: 'production', + action, + }); expect(result.ok).toBe(true); - expect(result.data).toHaveProperty('totalCount'); - expect(Array.isArray(result.data.errors)).toBe(true); + expect(result.data).toMatchObject({ + environment: 'production', + provider: 'railway', + ...expected, + }); await t.close(); }); }); diff --git a/src/tools/apply-plan.ts b/src/tools/apply-plan.ts index dc26e7c..40dddc3 100644 --- a/src/tools/apply-plan.ts +++ b/src/tools/apply-plan.ts @@ -56,7 +56,6 @@ import { isHostingEnvRemovalAction, removeHostingEnvVars, } from '../domain/services/hosting-env.service.js'; -import { StateManager } from '../agent/state.js'; import { getSecretStore } from '../adapters/secrets/secret-store.js'; import type { Project } from '../domain/entities/project.entity.js'; import type { Component } from '../domain/entities/component.entity.js'; @@ -548,10 +547,6 @@ export async function executePlanApply(ctx: ToolContext, params: { } } - if (result.success) { - syncAutofixWatches(ctx, applyProject, envName, envSpec); - } - return { kind: 'executed', envName, @@ -561,34 +556,6 @@ export async function executePlanApply(ctx: ToolContext, params: { }; } -/** Sync spec.autofix to the autofix agent's watch list after a successful apply. */ -function syncAutofixWatches( - ctx: ToolContext, - project: Project, - envName: string, - envSpec: EnvironmentSpec -): void { - if (!envSpec.autofix) return; - const environment = ctx.repos.environments.findByProjectAndName(project.id, envName); - if (!environment) return; - - try { - const stateManager = new StateManager(); - const serviceNames = envSpec.autofix.services ?? Object.keys(envSpec.services); - for (const serviceName of serviceNames) { - if (envSpec.autofix.enabled) { - stateManager.addWatch({ projectId: project.id, environmentId: environment.id, serviceName, enabled: true }); - } else { - stateManager.removeWatch(project.id, environment.id, serviceName); - } - } - stateManager.save(); - } catch (error) { - // Watch sync must never fail an apply. - console.warn(`[hypervibe] autofix watch sync failed: ${error instanceof Error ? error.message : String(error)}`); - } -} - function projectSpecReferencesService(spec: ProjectSpec, serviceName: string): boolean { return Object.values(spec.environments).some((environmentSpec) => Boolean(environmentSpec.services[serviceName])); } diff --git a/src/tools/hv-observability.tools.ts b/src/tools/hv-observability.tools.ts index 1dc0125..3ff4e19 100644 --- a/src/tools/hv-observability.tools.ts +++ b/src/tools/hv-observability.tools.ts @@ -1,6 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { StateManager, type TrackedError } from '../agent/state.js'; import { detectProviderName } from '../domain/services/provider-logs.service.js'; import { fetchProviderLogs, @@ -26,8 +25,6 @@ import type { ToolContext } from './context.js'; import { projectField, envField } from './schemas.js'; import { toolSuccess, toolError, wrapHandler, HvError } from './respond.js'; -const stateManager = new StateManager(); - function resolveEnvOrThrow(ctx: ToolContext, projectRef: string | undefined, envName: string | undefined) { const project = ctx.resolveProjectOrThrow({ project: projectRef }); const environment = ctx.resolveEnvironmentOrThrow(project, envName); @@ -104,42 +101,15 @@ export function registerHvObservabilityTools(server: McpServer, ctx: ToolContext server.tool( 'hv_errors', - 'Surface production errors: list recent error log lines, summarize error health per service, or manage autofix-tracked error fingerprints.', + 'Surface production errors: list recent error log lines or summarize error and deployment health per service.', { project: projectField, env: envField, - action: z.enum(['list', 'summary', 'tracked', 'ignore']).optional() - .describe('list = recent error log lines; summary = per-service error/deploy health; tracked = autofix-agent tracked errors; ignore = stop auto-fixing a tracked fingerprint. Default list.'), - limit: z.number().int().min(1).max(200).optional().describe('Max errors for list/tracked (default 20)'), - fingerprint: z.string().optional().describe('action=ignore: tracked error fingerprint'), - status: z.enum(['all', 'new', 'pr_created', 'ignored']).optional().describe('action=tracked: filter by status'), + action: z.enum(['list', 'summary']).optional() + .describe('list = recent error log lines; summary = per-service error/deploy health. Default list.'), + limit: z.number().int().min(1).max(200).optional().describe('Max errors for list (default 20)'), }, - wrapHandler(async ({ project: projectRef, env, action = 'list', limit = 20, fingerprint, status }) => { - if (action === 'tracked' || action === 'ignore') { - if (action === 'ignore') { - if (!fingerprint) { - throw new HvError('VALIDATION', 'fingerprint is required for action=ignore.'); - } - const error = stateManager.getError(fingerprint); - if (!error) { - return toolError('NOT_FOUND', `Tracked error not found: ${fingerprint}`); - } - stateManager.updateErrorStatus(fingerprint, 'ignored'); - stateManager.save(); - return toolSuccess({ fingerprint, ...stateManager.getError(fingerprint) }); - } - - let entries: Array<[string, TrackedError]> = Object.entries(stateManager.getAllErrors()); - if (status && status !== 'all') { - entries = entries.filter(([, e]) => e.status === status); - } - entries.sort((a, b) => new Date(b[1].lastSeen).getTime() - new Date(a[1].lastSeen).getTime()); - return toolSuccess({ - totalCount: entries.length, - errors: entries.slice(0, limit).map(([fp, e]) => ({ fingerprint: fp, ...e })), - }); - } - + wrapHandler(async ({ project: projectRef, env, action = 'list', limit = 20 }) => { const { project, environment, provider } = resolveEnvOrThrow(ctx, projectRef, env); if (action === 'summary') { const summary = await collectErrorsSummary(provider, project, environment);