diff --git a/browser-host/.gitignore b/browser-host/.gitignore new file mode 100644 index 000000000000..5e9eee0a8c1a --- /dev/null +++ b/browser-host/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/browser-host/README.md b/browser-host/README.md new file mode 100644 index 000000000000..8f2fdb2ef113 --- /dev/null +++ b/browser-host/README.md @@ -0,0 +1,84 @@ +# Hermes Browser Host — Phase 2A Skeleton + +This is a **minimal Electron app skeleton** for the Hermes Browser Workspace +embedded browser host. Phase 2A proves that an independent Electron child +process can start, open a window, and expose a localhost health endpoint. + +**It does NOT include:** +- Embedded browser surface (WebContentsView) — that is Phase 2B +- Snapshot / screenshot / DOM / clipboard extraction — that is Phase 2C +- Agent actions (click, type, submit, navigate) — those are future +- Dashboard start/stop controls — those are Phase 2B + +## Quick start + +```bash +cd browser-host +npm install +npm run typecheck +npm run build +npm run dev +``` + +## Verify health endpoint + +```bash +# Read the port from the state file +cat ~/.hermes/browser-host/state.json + +# Or curl directly (port 8765 or as shown in state.json) +curl http://127.0.0.1:8765/health +``` + +Expected response: + +```json +{ + "ok": true, + "service": "hermes-browser-host", + "phase": "2A", + "pid": 12345, + "port": 8765, + "startedAt": "2026-06-06T...", + "features": { + "embeddedBrowser": false, + "snapshot": false, + "screenshot": false, + "agentActions": false + } +} +``` + +## Stop + +Press `Ctrl+C` in the terminal, or kill the process: + +```bash +kill $(cat ~/.hermes/browser-host/state.json | python3 -c 'import sys,json; print(json.load(sys.stdin)["pid"])') +``` + +## State file + +`~/.hermes/browser-host/state.json` is written on startup and cleaned up +on normal exit (SIGTERM, SIGINT, app quit). If the process crashes or is +force-killed (SIGKILL), the state file may remain stale. + +## Security + +- HTTP server binds `127.0.0.1` only — not accessible from other machines +- Only `GET /health` is exposed — no write/action endpoints +- No click/type/submit/navigate API +- No user-provided JavaScript execution + +## Directory + +``` +browser-host/ + package.json + tsconfig.json + src/ + main.ts ← Electron main process + health server + renderer.html ← Simple status window + .gitignore + README.md +``` diff --git a/browser-host/package-lock.json b/browser-host/package-lock.json new file mode 100644 index 000000000000..b5415f6491fb --- /dev/null +++ b/browser-host/package-lock.json @@ -0,0 +1,819 @@ +{ + "name": "hermes-browser-host", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermes-browser-host", + "version": "0.2.0", + "dependencies": { + "electron": "^35.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "~5.8.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "35.7.5", + "resolved": "https://registry.npmmirror.com/electron/-/electron-35.7.5.tgz", + "integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.2.tgz", + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/browser-host/package.json b/browser-host/package.json new file mode 100644 index 000000000000..36ae9aed20af --- /dev/null +++ b/browser-host/package.json @@ -0,0 +1,21 @@ +{ + "name": "hermes-browser-host", + "version": "0.2.0", + "private": true, + "description": "Hermes Browser Workspace — Electron host (Phase 2A skeleton)", + "main": "dist/main.js", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc && cp src/renderer.html dist/renderer.html", + "dev": "npm run build && electron dist/main.js", + "start": "electron dist/main.js", + "validate:contract": "node scripts/validate-contract.mjs" + }, + "dependencies": { + "electron": "^35.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "~5.8.0" + } +} diff --git a/browser-host/scripts/validate-contract.mjs b/browser-host/scripts/validate-contract.mjs new file mode 100644 index 000000000000..936d4d16bb30 --- /dev/null +++ b/browser-host/scripts/validate-contract.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node + +/** + * Phase 5A contract validator. + * + * Validates that a JSON file matches the BrowserContextSnapshot schema. + * No dependencies — uses only Node.js built-ins. + * + * Usage: node scripts/validate-contract.mjs [path] + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const DEFAULTS = { + fixture: resolve(import.meta.dirname, "../test/contract-snapshot.json"), +}; + +function fail(msg) { + console.error(`FAIL: ${msg}`); + process.exitCode = 1; +} + +function assert(cond, msg) { + if (!cond) fail(msg); +} + +function required(obj, key, ctx) { + if (!(key in obj) || obj[key] === undefined) { + fail(`${ctx}: missing required field "${key}"`); + return false; + } + return true; +} + +function typeCheck(val, expected, ctx) { + const t = typeof val; + if (t !== expected) { + fail(`${ctx}: expected type "${expected}", got "${t}"`); + return false; + } + return true; +} + +function validate(path) { + let data; + try { + data = JSON.parse(readFileSync(path, "utf-8")); + } catch (e) { + fail(`Cannot read ${path}: ${e.message}`); + return; + } + + // Top-level + required(data, "capturedAt", "root"); + typeCheck(data.capturedAt, "string", "root.capturedAt"); + required(data, "source", "root"); + required(data, "profileId", "root"); + typeCheck(data.profileId, "string", "root.profileId"); + required(data, "activeTab", "root"); + required(data, "tabs", "root"); + assert(Array.isArray(data.tabs), "root.tabs must be array"); + required(data, "recentEvents", "root"); + assert(Array.isArray(data.recentEvents), "root.recentEvents must be array"); + required(data, "permissions", "root"); + required(data, "limits", "root"); + + // source + const src = data.source; + required(src, "provider", "source"); + assert(src.provider === "browser-host", 'source.provider must be "browser-host"'); + required(src, "mode", "source"); + assert(src.mode === "read-only", 'source.mode must be "read-only"'); + required(src, "version", "source"); + assert(["phase-3b", "phase-5a"].includes(src.version), `source.version must be "phase-3b" or "phase-5a", got "${src.version}"`); + + // activeTab + const tab = data.activeTab; + const tabFields = ["id", "url", "title", "pageType", "isLoading", "canGoBack", "canGoForward", "screenshotRef", "domSummary", "selectedText", "clipboardTextPreview"]; + for (const f of tabFields) { + required(tab, f, "activeTab"); + } + typeCheck(tab.isLoading, "boolean", "activeTab.isLoading"); + typeCheck(tab.canGoBack, "boolean", "activeTab.canGoBack"); + typeCheck(tab.canGoForward, "boolean", "activeTab.canGoForward"); + typeCheck(tab.domSummary, "string", "activeTab.domSummary"); + typeCheck(tab.selectedText, "string", "activeTab.selectedText"); + typeCheck(tab.clipboardTextPreview, "string", "activeTab.clipboardTextPreview"); + + // tabs + assert(data.tabs.length > 0, "tabs must not be empty"); + for (let i = 0; i < data.tabs.length; i++) { + const t = data.tabs[i]; + required(t, "id", `tabs[${i}]`); + required(t, "url", `tabs[${i}]`); + required(t, "title", `tabs[${i}]`); + typeCheck(t.active, "boolean", `tabs[${i}].active`); + } + + // recentEvents + for (let i = 0; i < data.recentEvents.length; i++) { + const e = data.recentEvents[i]; + required(e, "ts", `recentEvents[${i}]`); + required(e, "type", `recentEvents[${i}]`); + typeCheck(e.ts, "string", `recentEvents[${i}].ts`); + typeCheck(e.type, "string", `recentEvents[${i}].type`); + } + + // permissions + const perms = data.permissions; + for (const k of ["screenshot", "clipboard", "dom", "selection"]) { + required(perms, k, "permissions"); + assert(["available", "unavailable"].includes(perms[k]), `permissions.${k} must be "available" or "unavailable", got "${perms[k]}"`); + } + + // limits + const limits = data.limits; + assert(limits.domSummaryChars === 3000, "limits.domSummaryChars must be 3000"); + assert(limits.selectedTextChars === 3000, "limits.selectedTextChars must be 3000"); + assert(limits.clipboardPreviewChars === 1000, "limits.clipboardPreviewChars must be 1000"); + + // pluginContext — optional, must be null or object with pluginId + matched + if ("pluginContext" in data && data.pluginContext !== null) { + const pc = data.pluginContext; + assert(typeof pc === "object", "pluginContext must be null or object"); + required(pc, "pluginId", "pluginContext"); + typeCheck(pc.pluginId, "string", "pluginContext.pluginId"); + required(pc, "matched", "pluginContext"); + typeCheck(pc.matched, "boolean", "pluginContext.matched"); + if (pc.pluginId === "github-pr" && pc.matched) { + const gpr = pc.githubPullRequest; + assert(gpr && typeof gpr === "object", "pluginContext.githubPullRequest must be object when pluginId=github-pr and matched=true"); + required(gpr, "owner", "pluginContext.githubPullRequest"); + required(gpr, "repo", "pluginContext.githubPullRequest"); + typeCheck(gpr.number, "number", "pluginContext.githubPullRequest.number"); + required(gpr, "url", "pluginContext.githubPullRequest"); + required(gpr, "title", "pluginContext.githubPullRequest"); + } + } + + if (process.exitCode === undefined || process.exitCode === 0) { + console.log(`PASS: ${path}`); + } +} + +const arg = process.argv[2] || DEFAULTS.fixture; +validate(arg); diff --git a/browser-host/src/main.ts b/browser-host/src/main.ts new file mode 100644 index 000000000000..8ed129d99fa2 --- /dev/null +++ b/browser-host/src/main.ts @@ -0,0 +1,536 @@ +/** + * Hermes Browser Host — Phase 5A + * + * Electron app that: + * - Opens a window with embedded browser surface (WebContentsView) + * - User-controlled URL bar + navigation + * - Starts a localhost HTTP health server + * - Provides read-only /snapshot, /screenshot, /context API + * - Hermes-native plugin layer: detects GitHub PR + ChatGPT pages + * - Writes state to ~/.hermes/browser-host/state.json + * + * No Agent actions. No click/type/submit/navigateAsAgent. + * No user-provided JavaScript execution. + * executeJavaScript only used for built-in static read-only scripts. + */ + +import { app, BrowserWindow, WebContentsView, ipcMain, clipboard } from "electron"; +import { createServer, type IncomingMessage, type ServerResponse } from "http"; +import { writeFileSync, mkdirSync, unlinkSync, existsSync } from "fs"; +import { join } from "path"; +import { homedir } from "os"; +import { + buildSnapshot, + type BrowserContextSnapshot, + type BrowserEvent, + type PluginContext, +} from "./schema"; + +/* ── Constants ────────────────────────────────────────────────────── */ + +const STATE_DIR = join(homedir(), ".hermes", "browser-host"); +const STATE_FILE = join(STATE_DIR, "state.json"); +const HEALTH_PORT_FILE = join(STATE_DIR, "port"); +const BROWSER_PROFILE = join(homedir(), ".hermes", "browser"); + +/* ── Globals ──────────────────────────────────────────────────────── */ + +let healthPort = 0; +let startedAt = ""; +let mainWindow: BrowserWindow | null = null; +let browserView: WebContentsView | null = null; +let httpServer: ReturnType | null = null; +let pageReady = false; +const recentEvents: BrowserEvent[] = []; + +function pushEvent(type: string, extra: Partial = {}) { + recentEvents.push({ ts: new Date().toISOString(), type, ...extra }); + if (recentEvents.length > 50) recentEvents.shift(); +} + +/* ── State management ─────────────────────────────────────────────── */ + +function writeStateFile(): void { + const state = { + pid: process.pid, + port: healthPort, + healthUrl: `http://127.0.0.1:${healthPort}/health`, + startedAt, + }; + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +function cleanupStateFile(): void { + try { + if (existsSync(STATE_FILE)) unlinkSync(STATE_FILE); + if (existsSync(HEALTH_PORT_FILE)) unlinkSync(HEALTH_PORT_FILE); + } catch { + // best-effort cleanup + } +} + +/* ── Renderer + Browser surface ───────────────────────────────────── */ + +function createWindow(): void { + // Set persistent profile for login state + app.setPath("userData", BROWSER_PROFILE); + + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + title: "Hermes Browser Host (Phase 2C)", + webPreferences: { + preload: join(__dirname, "preload.js"), + nodeIntegration: false, + contextIsolation: true, + }, + }); + + // ── Embedded browser surface ────────────────────────────────── + browserView = new WebContentsView({ + webPreferences: { + // Inherits default session from the persistent profile + }, + }); + browserView.setBackgroundColor("#ffffff"); + mainWindow.contentView.addChildView(browserView); + + const wc = browserView.webContents; + wc.on("did-finish-load", () => { + pageReady = true; + pushEvent("page-load-complete", { url: wc.getURL() }); + mainWindow?.webContents.send("browser:url-changed", wc.getURL()); + mainWindow?.webContents.send("browser:loading", false); + }); + wc.on("did-start-loading", () => { + pageReady = false; + mainWindow?.webContents.send("browser:loading", true); + }); + wc.on("page-title-updated", (_ev, title) => { + pushEvent("page-title-updated", { title }); + }); + wc.on("did-navigate", (_ev, url) => { + pushEvent("page-navigation-committed", { url }); + }); + wc.on("did-fail-load", (_ev, _code, desc, url) => { + pushEvent("page-failed", { url, error: desc }); + mainWindow?.webContents.send("browser:loading", false); + }); + + // ── Layout ──────────────────────────────────────────────────── + const layoutBrowserView = () => { + if (!mainWindow || !browserView) return; + const cb = mainWindow.contentView.getBounds(); + const toolbarH = 48; + browserView.setBounds({ + x: 0, + y: toolbarH, + width: cb.width, + height: Math.max(100, cb.height - toolbarH), + }); + }; + layoutBrowserView(); + mainWindow.on("resize", layoutBrowserView); + + // ── IPC from renderer toolbar ───────────────────────────────── + ipcMain.handle("browser:go", (_ev, url: string) => { + if (!browserView || !url) return { error: "No browser surface" }; + try { + browserView.webContents.loadURL(url); + pushEvent("navigate-human", { url }); + return { success: true }; + } catch (e: unknown) { + return { error: e instanceof Error ? e.message : String(e) }; + } + }); + + ipcMain.handle("browser:back", () => { + if (browserView?.webContents.canGoBack()) { + browserView.webContents.goBack(); + pushEvent("go-back", {}); + return { success: true }; + } + return { success: false }; + }); + + ipcMain.handle("browser:forward", () => { + if (browserView?.webContents.canGoForward()) { + browserView.webContents.goForward(); + pushEvent("go-forward", {}); + return { success: true }; + } + return { success: false }; + }); + + ipcMain.handle("browser:getInfo", () => { + if (!browserView) return { url: "", title: "", loading: false }; + return { + url: browserView.webContents.getURL(), + title: browserView.webContents.getTitle(), + loading: browserView.webContents.isLoading(), + }; + }); + + mainWindow.loadFile(join(__dirname, "renderer.html")); + mainWindow.on("closed", () => { mainWindow = null; }); +} + +/* ── Hermes-native plugin detector (Phase 5A) ────────────────────────── */ + +/** + * Detects known page types from URL + title. + * Extensible: add new detectors here. + * No network calls. No user JS. URL parsing only. + */ +function detectPluginContext(url: string, title: string): { + pageType: string; + pluginContext: PluginContext | null; +} { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { pageType: "generic-web", pluginContext: null }; + } + + // ── GitHub Pull Request detector ────────────────────────────── + if (parsed.hostname === "github.com") { + const prMatch = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/); + if (prMatch) { + const owner = prMatch[1]; + const repo = prMatch[2]; + const prNumber = parseInt(prMatch[3], 10); + + let prTitle = title; + const pullIdx = title.lastIndexOf(" · Pull Request #"); + if (pullIdx > 0) { + const byIdx = title.lastIndexOf(" by ", pullIdx); + prTitle = byIdx > 0 ? title.substring(0, byIdx) : title.substring(0, pullIdx); + } + + return { + pageType: "github_pull_request", + pluginContext: { + pluginId: "github-pr", + matched: true, + githubPullRequest: { + owner, + repo, + number: prNumber, + url: `https://github.com/${owner}/${repo}/pull/${prNumber}`, + title: prTitle, + }, + }, + }; + } + } + + // ── ChatGPT conversation detector ───────────────────────────── + if (parsed.hostname === "chatgpt.com" || parsed.hostname === "chat.openai.com") { + // Match /c/{conversationId} (specific conversation) + const convMatch = parsed.pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); + const conversationId = convMatch ? convMatch[1] : null; + const pageType = conversationId ? "chatgpt_conversation" : "chatgpt_home"; + + return { + pageType, + pluginContext: { + pluginId: "chatgpt-conversation", + matched: true, + chatgptConversation: { + url, + title, + conversationId, + }, + }, + }; + } + + return { pageType: "generic-web", pluginContext: null }; +} + +/* ── Snapshot / context (shared schema) ────────────────────────────── */ + +function getBaseParams(): { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; +} { + if (!browserView) { + return { url: "", title: "", isLoading: false, canGoBack: false, canGoForward: false }; + } + const wc = browserView.webContents; + return { + url: wc.getURL(), + title: wc.getTitle(), + isLoading: wc.isLoading(), + canGoBack: wc.canGoBack(), + canGoForward: wc.canGoForward(), + }; +} + +function getSnapshot(): BrowserContextSnapshot { + const base = getBaseParams(); + const detection = detectPluginContext(base.url, base.title); + return buildSnapshot({ + ...base, + domSummary: "", + selectedText: "", + clipboardTextPreview: "", + recentEvents, + pageType: detection.pageType, + pluginContext: detection.pluginContext, + }); +} + +/* ── Context extraction ────────────────────────────────────────────── */ + +/** Built-in static read-only scripts. Never accept user/Agent input. */ +const EXTRACT_DOM_SCRIPT = `(function(){ + try { return (document.body ? document.body.innerText : '').slice(0, 3000); } + catch(e) { return ''; } +})()`; + +const EXTRACT_SELECTION_SCRIPT = `(function(){ + try { var s = window.getSelection(); return s ? s.toString().slice(0, 3000) : ''; } + catch(e) { return ''; } +})()`; + +async function getContext(): Promise { + const base = getBaseParams(); + const detection = detectPluginContext(base.url, base.title); + + let domSummary = ""; + let selectedText = ""; + let clipboardTextPreview = ""; + + try { + domSummary = + (await browserView!.webContents.executeJavaScript(EXTRACT_DOM_SCRIPT)) || ""; + } catch { + domSummary = ""; + } + + try { + selectedText = + (await browserView!.webContents.executeJavaScript(EXTRACT_SELECTION_SCRIPT)) || ""; + } catch { + selectedText = ""; + } + + try { + clipboardTextPreview = clipboard.readText().slice(0, 1000); + } catch { + clipboardTextPreview = ""; + } + + return buildSnapshot({ + ...base, + domSummary, + selectedText, + clipboardTextPreview, + recentEvents, + pageType: detection.pageType, + pluginContext: detection.pluginContext, + }); +} + +/* ── HTTP server ──────────────────────────────────────────────────── */ + +function routeRequest(req: IncomingMessage, res: ServerResponse): void { + const url = req.url || "/"; + const method = req.method || "GET"; + + // CORS for localhost + res.setHeader("Access-Control-Allow-Origin", "http://127.0.0.1:9119"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (url === "/health" || url === "/health/") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + ok: true, + service: "hermes-browser-host", + phase: "5A", + pid: process.pid, + port: healthPort, + startedAt, + features: { + embeddedBrowser: true, + snapshot: true, + screenshot: true, + agentActions: false, + plugins: true, + }, + }) + ); + return; + } + + if (url === "/snapshot" || url === "/snapshot/") { + if (!browserView) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No browser surface" })); + return; + } + const snap = getSnapshot(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(snap)); + return; + } + + if (url === "/screenshot" || url === "/screenshot/") { + if (!browserView) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No browser surface" })); + return; + } + if (!pageReady) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Page not loaded yet" })); + return; + } + browserView.webContents + .capturePage() + .then((image) => { + const buf = image.toPNG(); + if (buf.length === 0) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Zero-byte capture" })); + return; + } + // Limit to max 1920px wide + const base64 = buf.toString("base64"); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + format: "png", + sizeBytes: buf.length, + dataUri: `data:image/png;base64,${base64}`, + }) + ); + pushEvent("screenshot-captured", { sizeBytes: buf.length }); + }) + .catch((err: Error) => { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + }); + return; + } + + if (url === "/context" || url === "/context/") { + if (!browserView) { + res.writeHead(503, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No browser surface" })); + return; + } + getContext().then((ctx) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(ctx)); + }).catch((err: Error) => { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + }); + return; + } + + // 404 + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); +} + +function startHttpServer(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(routeRequest); + + function tryListen(port: number): void { + server.listen(port, "127.0.0.1", () => { + healthPort = port; + httpServer = server; + resolve(port); + }); + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + server.close(); + const retry = createServer(routeRequest); + retry.listen(port === 8765 ? 0 : port + 1, "127.0.0.1", () => { + healthPort = port === 8765 ? 0 : port + 1; + httpServer = retry; + resolve(healthPort); + }); + retry.once("error", () => reject(err)); + } else { + reject(err); + } + }); + } + + tryListen(8765); + }); +} + +/* ── Cleanup ──────────────────────────────────────────────────────── */ + +function cleanup(): void { + cleanupStateFile(); + if (httpServer) { + httpServer.close(); + httpServer = null; + } +} + +/* ── App lifecycle ────────────────────────────────────────────────── */ + +app.whenReady().then(async () => { + startedAt = new Date().toISOString(); + + try { + await startHttpServer(); + } catch (err) { + console.error("Failed to start HTTP server:", err); + app.quit(); + return; + } + + writeStateFile(); + createWindow(); + + console.log( + `Hermes Browser Host Phase 5A running`, + `\n Health: http://127.0.0.1:${healthPort}/health`, + `\n Context: http://127.0.0.1:${healthPort}/context`, + `\n State: ${STATE_FILE}` + ); +}); + +app.on("window-all-closed", () => { + // Don't quit — keep the HTTP server alive +}); + +app.on("before-quit", () => { + cleanup(); +}); + +process.on("SIGTERM", () => { + cleanup(); + app.quit(); +}); + +process.on("SIGINT", () => { + cleanup(); + app.quit(); +}); + +process.on("exit", () => { + cleanupStateFile(); +}); diff --git a/browser-host/src/preload.ts b/browser-host/src/preload.ts new file mode 100644 index 000000000000..1cbe834e9420 --- /dev/null +++ b/browser-host/src/preload.ts @@ -0,0 +1,20 @@ +/** + * Hermes Browser Host — Preload (Phase 2C) + * + * Exposes toolbar navigation to the renderer window. + * No Agent APIs. No context bridge for snapshot/screenshot. + */ +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("toolbarAPI", { + go: (url: string) => ipcRenderer.invoke("browser:go", url), + back: () => ipcRenderer.invoke("browser:back"), + forward: () => ipcRenderer.invoke("browser:forward"), + getInfo: () => ipcRenderer.invoke("browser:getInfo"), + onUrlChanged: (cb: (url: string) => void) => { + ipcRenderer.on("browser:url-changed", (_ev, url) => cb(url)); + }, + onLoading: (cb: (loading: boolean) => void) => { + ipcRenderer.on("browser:loading", (_ev, loading) => cb(loading)); + }, +}); diff --git a/browser-host/src/renderer.html b/browser-host/src/renderer.html new file mode 100644 index 000000000000..f2e5a96cd734 --- /dev/null +++ b/browser-host/src/renderer.html @@ -0,0 +1,89 @@ + + + + + + Browser Host (Phase 2C) + + + +
+ + + + + Phase 2C +
+
+ + +
+ + + diff --git a/browser-host/src/schema.ts b/browser-host/src/schema.ts new file mode 100644 index 000000000000..77e8a0fcbb03 --- /dev/null +++ b/browser-host/src/schema.ts @@ -0,0 +1,140 @@ +/** + * Hermes Browser Host — Shared schema (Phase 5A) + * + * Single BrowserContextSnapshot contract used by both /snapshot and /context. + * /snapshot returns the same shape with domSummary/selectedText/clipboardTextPreview + * as empty strings. /context fills them via read-only extraction. + * + * No Agent actions. No user-provided JavaScript execution. + */ + +/** Event logged during the browser host session. */ +export interface BrowserEvent { + ts: string; + type: string; + tabId?: string; + url?: string; + title?: string; + [key: string]: unknown; +} + +/** A single browser tab. */ +export interface BrowserTab { + id: string; + url: string; + title: string; + active: boolean; +} + +/** The currently-focused tab with extended fields. */ +export interface ActiveTab { + id: string; + url: string; + title: string; + pageType: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + screenshotRef: string | null; + domSummary: string; + selectedText: string; + clipboardTextPreview: string; +} + +/** Host-set permission flags for each context dimension. */ +export interface Permissions { + screenshot: "available" | "unavailable"; + clipboard: "available" | "unavailable"; + dom: "available" | "unavailable"; + selection: "available" | "unavailable"; +} + +/** Character limits enforced by the host. */ +export interface Limits { + domSummaryChars: 3000; + selectedTextChars: 3000; + clipboardPreviewChars: 1000; +} + +/** Hermes-native plugin detection result. */ +export interface PluginContext { + pluginId: string; + matched: boolean; + [key: string]: unknown; +} + +/** Stable BrowserContextSnapshot contract. */ +export interface BrowserContextSnapshot { + capturedAt: string; + source: { + provider: "browser-host"; + mode: "read-only"; + version: "phase-5a"; + }; + profileId: string; + activeTab: ActiveTab; + tabs: BrowserTab[]; + recentEvents: BrowserEvent[]; + permissions: Permissions; + limits: Limits; + pluginContext: PluginContext | null; +} + +/** Build a BrowserContextSnapshot from raw data. */ +export function buildSnapshot(params: { + url: string; + title: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + domSummary: string; + selectedText: string; + clipboardTextPreview: string; + recentEvents: BrowserEvent[]; + pageType?: string; + pluginContext?: PluginContext | null; +}): BrowserContextSnapshot { + return { + capturedAt: new Date().toISOString(), + source: { + provider: "browser-host", + mode: "read-only", + version: "phase-5a", + }, + profileId: "default", + activeTab: { + id: "main", + url: params.url, + title: params.title, + pageType: params.pageType || "generic-web", + isLoading: params.isLoading, + canGoBack: params.canGoBack, + canGoForward: params.canGoForward, + screenshotRef: null, + domSummary: params.domSummary, + selectedText: params.selectedText, + clipboardTextPreview: params.clipboardTextPreview, + }, + tabs: [ + { + id: "main", + url: params.url, + title: params.title, + active: true, + }, + ], + recentEvents: params.recentEvents.slice(-20), + permissions: { + screenshot: "available", + clipboard: "available", + dom: "available", + selection: "available", + }, + limits: { + domSummaryChars: 3000, + selectedTextChars: 3000, + clipboardPreviewChars: 1000, + }, + pluginContext: params.pluginContext ?? null, + }; +} diff --git a/browser-host/test/contract-snapshot.json b/browser-host/test/contract-snapshot.json new file mode 100644 index 000000000000..75944a2ace6c --- /dev/null +++ b/browser-host/test/contract-snapshot.json @@ -0,0 +1,50 @@ +{ + "comment": "BrowserContextSnapshot contract fixture — Phase 5A. Both /snapshot and /context must match this shape.", + "capturedAt": "2026-06-06T10:00:00.000Z", + "source": { + "provider": "browser-host", + "mode": "read-only", + "version": "phase-5a" + }, + "profileId": "default", + "activeTab": { + "id": "main", + "url": "https://example.com", + "title": "Example Domain", + "pageType": "generic-web", + "isLoading": false, + "canGoBack": false, + "canGoForward": false, + "screenshotRef": null, + "domSummary": "Example Domain\n\nThis domain is for use in illustrative examples...", + "selectedText": "Example Domain", + "clipboardTextPreview": "" + }, + "tabs": [ + { + "id": "main", + "url": "https://example.com", + "title": "Example Domain", + "active": true + } + ], + "recentEvents": [ + { + "ts": "2026-06-06T10:00:00.000Z", + "type": "page-load-complete", + "url": "https://example.com" + } + ], + "permissions": { + "screenshot": "available", + "clipboard": "available", + "dom": "available", + "selection": "available" + }, + "limits": { + "domSummaryChars": 3000, + "selectedTextChars": 3000, + "clipboardPreviewChars": 1000 + }, + "pluginContext": null +} diff --git a/browser-host/tsconfig.json b/browser-host/tsconfig.json new file mode 100644 index 000000000000..25022274b8a8 --- /dev/null +++ b/browser-host/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/executors/__init__.py b/executors/__init__.py new file mode 100644 index 000000000000..1a023459bd13 --- /dev/null +++ b/executors/__init__.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +Executor adapter layer for Hermes Desktop. + +Provides a uniform interface for launching, monitoring, and stopping +agent execution backends (Hermes Local, Claude Code CLI, Codex CLI, +OpenCode, DeepSeek TUI). + +Architecture: + UI -> ExecutorRegistry.get(executor_id).start(run) -> subprocess / HTTP / stub + UI -> ExecutorRegistry.get(executor_id).stream_events() -> unified RunEvent stream + UI <- ExecutorRegistry.check_all_health() -> health status for all registered executors +""" + +from executors.types import ( + ExecutorId, + ExecutorHealthStatus, + ExecutorHealthResult, + ExecutorCapabilities, + ExecutorManifest, + RunEventType, + RunEvent, + RunStatus, + AgentRun, + ExecutorConfig, + AdapterStartResult, + # v0.4+ worktree + WorktreeStatus, + WorktreeAllocation, + # v0.5+ router + RouterRecommendation, + TaskCreateContext, + # v0.6+ context + AdrSummary, + CommandEntry, + RecentTask, + ProjectContext, + PromptSnapshot, + # v0.7+ review/qa + Severity, + FindingCategory, + ReviewStatus, + QAStatus, + RunType, + ReviewFinding, + ReviewReport, + QARisk, + QAReport, + # v0.8+ inbox + InboxSource, + InboxStatus, + InboxItem, + TaskDraft, + InboxResultCallback, +) +from executors.registry import ExecutorRegistry +from executors.health import ( + check_executor_health, + check_all_executors_health, + make_unknown_health, + make_available_health, + make_unavailable_health, +) +from executors.router import ExecutorRouter, create_default_router + +__all__ = [ + "ExecutorId", + "ExecutorHealthStatus", + "ExecutorHealthResult", + "ExecutorCapabilities", + "ExecutorManifest", + "RunEventType", + "RunEvent", + "RunStatus", + "AgentRun", + "ExecutorConfig", + "AdapterStartResult", + "WorktreeStatus", + "WorktreeAllocation", + "RouterRecommendation", + "TaskCreateContext", + "AdrSummary", + "CommandEntry", + "RecentTask", + "ProjectContext", + "PromptSnapshot", + "Severity", + "FindingCategory", + "ReviewStatus", + "QAStatus", + "RunType", + "ReviewFinding", + "ReviewReport", + "QARisk", + "QAReport", + "InboxSource", + "InboxStatus", + "InboxItem", + "TaskDraft", + "InboxResultCallback", + "ExecutorRegistry", + "check_executor_health", + "check_all_executors_health", + "make_unknown_health", + "make_available_health", + "make_unavailable_health", + "ExecutorRouter", + "create_default_router", +] diff --git a/executors/bridge.py b/executors/bridge.py new file mode 100644 index 000000000000..89b7c6744cb5 --- /dev/null +++ b/executors/bridge.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +""" +RunBridge — aggregates RunEvents into Logs/Diff/ChangedFiles views. + +This is the bridge between the Python executor backend and the desktop UI +(Electron + IPC). It consumes normalized RunEvents and produces the +structured views that the UI renders: LogsTab, DiffTab, ChangedFilesPanel. + +v1.0 release blocker: makes the full main link functional: + Task → Run → Logs → Changed Files → Diff → Continue/Retry → Review/QA → Done +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Set + +from executors.types import ( + ExecutorId, + ExecutorConfig, + ProjectContext, + PromptSnapshot, + RunEvent, + RunEventType, + RunStatus, + RunType, +) + +# --------------------------------------------------------------------------- +# ChangedFile +# --------------------------------------------------------------------------- + + +@dataclass +class ChangedFile: + """A single file changed during a run.""" + path: str + status: str # "added" | "modified" | "deleted" + additions: int = 0 + deletions: int = 0 + diff_patch: str = "" + + +# --------------------------------------------------------------------------- +# RunResult — aggregated output of a completed run +# --------------------------------------------------------------------------- + + +@dataclass +class RunResult: + """Complete aggregate of a run's events, produced by RunBridge.""" + run_id: str + status: RunStatus = RunStatus.PENDING + error_summary: Optional[str] = None + + # Logs + logs: List[_LogEntry] = field(default_factory=list) + gemini_app_logs: List[str] = field(default_factory=list) # renamed from "tool logs" + + # Changed files (aggregated from diff events) + changed_files: List[ChangedFile] = field(default_factory=list) + + # Diff events (raw patches) + diff_patches: List[str] = field(default_factory=list) + + # Tool calls + tool_calls: List[_ToolCall] = field(default_factory=list) + + # Message deltas + message_parts: List[str] = field(default_factory=list) + + # Reasoning blocks + reasoning_blocks: List[str] = field(default_factory=list) + + # Stats + total_events: int = 0 + events_by_type: Dict[str, int] = field(default_factory=dict) + + def full_message(self) -> str: + return "".join(self.message_parts) + + def has_changes(self) -> bool: + return len(self.changed_files) > 0 + + def has_diff(self) -> bool: + return len(self.diff_patches) > 0 + + def to_summary(self) -> str: + """One-line summary for status bar / task card.""" + if self.status == RunStatus.RUNNING: + return f"Running ({len(self.message_parts)} message parts, {len(self.tool_calls)} tool calls)" + if self.status == RunStatus.COMPLETED: + changed = f", {len(self.changed_files)} files changed" if self.changed_files else "" + return f"Completed ({self.total_events} events{changed})" + if self.status == RunStatus.FAILED: + return f"Failed: {self.error_summary or 'unknown error'}" + return self.status.value + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _LogEntry: + tool: str + message: str + level: str = "info" + + +@dataclass +class _ToolCall: + seq: int + tool_name: str + args: Optional[dict] = None + duration: float = 0.0 + error: bool = False + output: Optional[str] = None + stdout: Optional[str] = None + stderr: Optional[str] = None + + +# --------------------------------------------------------------------------- +# RunBridge +# --------------------------------------------------------------------------- + + +class RunBridge: + """Aggregates a stream of RunEvents into structured views. + + Usage:: + + bridge = RunBridge() + for event in adapter.stream_events(run_id): + bridge.ingest(event) + result = bridge.finalize() + print(result.changed_files) + for log in result.logs: + print(f"[{log.tool}] {log.message}") + """ + + def __init__(self, run_id: Optional[str] = None): + self.run_id = run_id or "" + self._run_id: Optional[str] = None # set on first event + self._status: RunStatus = RunStatus.PENDING + self._error_summary: Optional[str] = None + + self._logs: List[_LogEntry] = [] + self._changed_files: List[ChangedFile] = [] + self._diff_patches: List[str] = [] + self._tool_calls: List[_ToolCall] = [] + self._message_parts: List[str] = [] + self._reasoning_blocks: List[str] = [] + + self._total_events = 0 + self._events_by_type: Dict[str, int] = {} + + # Track tool calls by their seq for pairing started/completed + self._pending_tools: Dict[int, _ToolCall] = {} + + # ------------------------------------------------------------------ + # Ingest + # ------------------------------------------------------------------ + + def ingest(self, event: RunEvent) -> None: + """Process a single RunEvent.""" + if self._run_id is None: + self._run_id = event.payload.get("run_id", self.run_id) + + self._total_events += 1 + et = event.type.value + self._events_by_type[et] = self._events_by_type.get(et, 0) + 1 + + handler = getattr(self, f"_handle_{event.type.value}", None) + if handler: + handler(event) + else: + # Unknown event type — log as generic + self._logs.append(_LogEntry( + tool="system", + message=f"Unknown event: {event.type.value} payload={str(event.payload)[:200]}", + level="warn", + )) + + def _handle_message(self, event: RunEvent) -> None: + content = event.payload.get("content", "") + if content: + self._message_parts.append(content) + + def _handle_reasoning(self, event: RunEvent) -> None: + content = event.payload.get("content", "") + if content: + self._reasoning_blocks.append(content) + + def _handle_tool_call(self, event: RunEvent) -> None: + tc = _ToolCall( + seq=event.seq or 0, + tool_name=event.payload.get("tool_name", "unknown"), + args=event.payload.get("arguments"), + ) + self._pending_tools[tc.seq] = tc + + def _handle_tool_result(self, event: RunEvent) -> None: + # Try to pair with a pending tool call + tool_call_id = event.payload.get("tool_call_id", "") + content = event.payload.get("content", "") + tool_name = event.payload.get("tool_name", "unknown") + + # Extract file changes from tool results + self._extract_files_from_content(tool_name, content) + + # Pair with pending tool call (by seq or tool_call_id) + tc = None + for seq, pending in list(self._pending_tools.items()): + if pending.tool_name in tool_name or tool_name in pending.tool_name: + tc = pending + del self._pending_tools[seq] + break + + if tc is None: + tc = _ToolCall(seq=event.seq or 0, tool_name=tool_name) + + tc.stdout = event.payload.get("stdout") + tc.stderr = event.payload.get("stderr") + tc.output = content[:1000] if content else None + tc.duration = event.payload.get("duration", 0.0) + tc.error = event.payload.get("is_error", False) + self._tool_calls.append(tc) + + # Generate log entries from tool output + if tc.stdout: + for line in tc.stdout.split("\n")[:50]: + if line.strip(): + self._logs.append(_LogEntry(tool=tc.tool_name, message=line, level="info")) + if tc.stderr: + for line in tc.stderr.split("\n")[:20]: + if line.strip(): + self._logs.append(_LogEntry(tool=tc.tool_name, message=line, level="warn")) + if tc.error: + self._logs.append(_LogEntry( + tool=tc.tool_name, + message=f"Tool failed: {content[:200] if content else 'no output'}", + level="error", + )) + + def _handle_log(self, event: RunEvent) -> None: + tool = event.payload.get("tool", event.payload.get("tool_name", "system")) + message = event.payload.get("message", event.payload.get("line", "")) + level = event.payload.get("level", "info") + if message.strip(): + self._logs.append(_LogEntry(tool=tool, message=message[:500], level=level)) + + def _handle_diff(self, event: RunEvent) -> None: + patch = event.payload.get("patch", "") + if patch: + self._diff_patches.append(patch) + files = self._parse_diff_patch(patch, self._run_id or "unknown") + self._changed_files.extend(files) + + def _handle_completed(self, event: RunEvent) -> None: + self._status = RunStatus.COMPLETED + + def _handle_failed(self, event: RunEvent) -> None: + self._status = RunStatus.FAILED + self._error_summary = event.payload.get("error_summary", "Run failed") + + # ------------------------------------------------------------------ + # Diff parsing + # ------------------------------------------------------------------ + + def _parse_diff_patch(self, patch: str, run_id: str) -> List[ChangedFile]: + """Parse a unified diff into ChangedFile objects.""" + files: List[ChangedFile] = [] + blocks = re.split(r"^diff --git ", patch, flags=re.MULTILINE)[1:] + + for block in blocks: + path_match = re.match(r"a/(.*?) b/(.*?)$", block, re.MULTILINE) + if not path_match: + continue + path = path_match.group(1) + + additions = len(re.findall(r"^\+(?!\+\+)", block, re.MULTILINE)) + deletions = len(re.findall(r"^-(?!--)", block, re.MULTILINE)) + + if additions > 0 and deletions == 0: + status = "added" + elif additions == 0 and deletions > 0: + status = "deleted" + else: + status = "modified" + + files.append(ChangedFile( + path=path, + status=status, + additions=additions, + deletions=deletions, + diff_patch=block[:5000], # cap per-file diff + )) + + return files + + def _extract_files_from_content(self, tool_name: str, content: str) -> None: + """Heuristic: extract file paths from tool output (e.g. write_file results).""" + # Look for patterns like "Wrote to /path/to/file.py" or "Created file /path/to/file.py" + patterns = [ + r"(?:Wrote|Created|Modified|Updated)\s+(?:to\s+)?([/\w.\-]+\.(?:py|ts|js|tsx|jsx|json|yaml|yml|md|css|html))", + r"File\s+([/\w.\-]+\.(?:py|ts|js|tsx|jsx|json|yaml|yml|md|css|html))\s+(?:written|created|modified)", + ] + for pat in patterns: + matches = re.findall(pat, content, re.IGNORECASE) + for m in matches: + # Avoid duplicates + if not any(cf.path == m for cf in self._changed_files): + self._changed_files.append(ChangedFile( + path=m, + status="modified", + )) + + # ------------------------------------------------------------------ + # Finalize + # ------------------------------------------------------------------ + + def finalize(self) -> RunResult: + """Return the aggregated RunResult.""" + return RunResult( + run_id=self._run_id or self.run_id, + status=self._status, + error_summary=self._error_summary, + logs=self._logs, + changed_files=self._changed_files, + diff_patches=self._diff_patches, + tool_calls=self._tool_calls, + message_parts=self._message_parts, + reasoning_blocks=self._reasoning_blocks, + total_events=self._total_events, + events_by_type=self._events_by_type, + ) + + +# --------------------------------------------------------------------------- +# IPC protocol models +# --------------------------------------------------------------------------- + + +@dataclass +class IPCEvent: + """An event that can be sent over the IPC bridge to the Electron UI.""" + event: str # e.g. "tool.completed", "diff", "message.delta" + run_id: str + timestamp: float + payload: dict = field(default_factory=dict) + + +@dataclass +class IPCChangedFile: + """Changed file data sent over IPC.""" + path: str + status: str + additions: int = 0 + deletions: int = 0 + absolute_path: str = "" + + +def result_to_ipc_events(result: RunResult) -> List[IPCEvent]: + """Convert a RunResult to a list of IPC events for the UI.""" + events: List[IPCEvent] = [] + import time + ts = time.time() + + # Logs + for log in result.logs: + events.append(IPCEvent( + event="tool.log", + run_id=result.run_id, + timestamp=ts, + payload={"tool": log.tool, "message": log.message, "level": log.level}, + )) + + # Diff + for patch in result.diff_patches: + events.append(IPCEvent( + event="diff", + run_id=result.run_id, + timestamp=ts, + payload={"patch": patch, "files_changed": len(result.changed_files)}, + )) + + # Changed files + for cf in result.changed_files: + events.append(IPCEvent( + event="changed_file", + run_id=result.run_id, + timestamp=ts, + payload={"path": cf.path, "status": cf.status, "additions": cf.additions, "deletions": cf.deletions}, + )) + + # Status + events.append(IPCEvent( + event="run.completed" if result.status == RunStatus.COMPLETED else "run.failed", + run_id=result.run_id, + timestamp=ts, + payload={"status": result.status.value, "error_summary": result.error_summary}, + )) + + return events diff --git a/executors/bridge_cli.py b/executors/bridge_cli.py new file mode 100644 index 000000000000..64686df1c462 --- /dev/null +++ b/executors/bridge_cli.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +""" +Bridge CLI — v1.0 acceptance path for the main link. + +Simulates what the Electron UI would do through IPC, using CLI commands +backed by the RunBridge aggregator. This allows manual end-to-end +acceptance without an Electron app. + +Usage (via executors.cli): + # Fixture-based acceptance (no real executor needed) + python -m executors.cli bridge accept --scenario happy-path + python -m executors.cli bridge accept --scenario failed + python -m executors.cli bridge accept --scenario all + + # View aggregated views from fixture events + python -m executors.cli bridge logs --fixture happy-path + python -m executors.cli bridge changed-files --fixture happy-path + python -m executors.cli bridge diff --fixture happy-path + + # IPC simulation + python -m executors.cli bridge ipc trigger-review --main-run-id run-001 + python -m executors.cli bridge ipc trigger-qa --main-run-id run-001 +""" + +from __future__ import annotations + +import datetime +import json +import sys +import time +from pathlib import Path +from typing import List, Optional + +from executors.bridge import RunBridge, RunResult, ChangedFile +from executors.types import ( + ExecutorId, + FindingCategory, + QAReport, + QAStatus, + ReviewFinding, + ReviewReport, + ReviewStatus, + RunEvent, + RunEventType, + RunStatus, + RunType, + Severity, +) + + +# --------------------------------------------------------------------------- +# Fixture event generators — simulate real runs for acceptance testing +# --------------------------------------------------------------------------- + + +def _fixture_happy_path() -> List[RunEvent]: + """Simulate a successful agent run with file changes.""" + events: List[RunEvent] = [] + rid = "run-happy-001" + ts_base = time.time() + + def _evt(t: RunEventType, payload: dict, offset: float = 0.0): + events.append(RunEvent( + type=t, + payload=payload, + seq=len(events), + timestamp=ts_base + offset, + )) + + # Agent starts reasoning + _evt(RunEventType.REASONING, {"content": "Let me analyze the auth module refactoring request."}, 0.1) + _evt(RunEventType.REASONING, {"content": "I need to: 1) read the current code 2) implement OAuth2 flow 3) add tests"}, 0.3) + + # Tool: read_file + _evt(RunEventType.TOOL_CALL, {"tool_name": "read_file", "arguments": {"path": "services/auth.py"}}, 0.5) + _evt(RunEventType.TOOL_RESULT, { + "tool_name": "read_file", + "tool_call_id": "tc-001", + "content": "def login(username, password):\n query = f\"SELECT * FROM users WHERE username='{username}'\"\n ...", + "duration": 0.15, + }, 1.0) + + # Message to user + _evt(RunEventType.MESSAGE, {"content": "I've read the current auth module. I'll now implement OAuth2 flow."}, 1.2) + + # Tool: write_file + _evt(RunEventType.TOOL_CALL, {"tool_name": "write_file", "arguments": {"path": "services/auth.py"}}, 1.3) + _evt(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "tool_call_id": "tc-002", + "content": "Wrote 142 lines to services/auth.py", + "stdout": "File written successfully\n42 additions, 18 deletions", + "duration": 0.05, + }, 1.8) + + # Tool: write_file (new file) + _evt(RunEventType.TOOL_CALL, {"tool_name": "write_file", "arguments": {"path": "services/auth_errors.py"}}, 2.0) + _evt(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "tool_call_id": "tc-003", + "content": "Created new file services/auth_errors.py with 28 lines", + "stdout": "File created successfully", + "duration": 0.03, + }, 2.3) + + # Tool: write_file (test) + _evt(RunEventType.TOOL_CALL, {"tool_name": "write_file", "arguments": {"path": "tests/test_auth.py"}}, 2.5) + _evt(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "tool_call_id": "tc-004", + "content": "Wrote 35 lines to tests/test_auth.py", + "stdout": "File written successfully\n31 additions, 5 deletions", + "duration": 0.04, + }, 2.9) + + # Diff event (generated by adapter after run) + diff_patch = ( + "diff --git a/services/auth.py b/services/auth.py\n" + "--- a/services/auth.py\n" + "+++ b/services/auth.py\n" + "@@ -15,6 +15,8 @@\n" + "-def login(username, password):\n" + "- query = f\"SELECT * FROM users WHERE username='{username}'\"\n" + "+def login(token: str) -> User:\n" + "+ payload = jwt.decode(token, SECRET, algorithms=['HS256'])\n" + "+ return User.query.get(payload['sub'])\n" + "+def refresh_token(token: str) -> str:\n" + "+ return create_token(...)\n" + "\n" + "diff --git a/services/auth_errors.py b/services/auth_errors.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/services/auth_errors.py\n" + "@@ -0,0 +1,28 @@\n" + "+class AuthError(Exception): pass\n" + "+class TokenExpiredError(AuthError): pass\n" + "+class InvalidTokenError(AuthError): pass\n" + "\n" + "diff --git a/tests/test_auth.py b/tests/test_auth.py\n" + "--- a/tests/test_auth.py\n" + "+++ b/tests/test_auth.py\n" + "@@ -10,5 +10,31 @@\n" + "+def test_token_validation():\n" + "+ assert login(valid_token).username == 'test'\n" + "+def test_expired_token():\n" + "+ with pytest.raises(TokenExpiredError):\n" + "+ login(expired_token)\n" + ) + _evt(RunEventType.DIFF, {"patch": diff_patch}, 3.0) + + # Run completes + _evt(RunEventType.COMPLETED, {"summary": "Auth module refactored with OAuth2 support"}, 3.1) + + return events + + +def _fixture_failed_path() -> List[RunEvent]: + """Simulate a failed agent run.""" + events: List[RunEvent] = [] + rid = "run-failed-001" + ts_base = time.time() + + def _evt(t: RunEventType, payload: dict, offset: float = 0.0): + events.append(RunEvent(type=t, payload=payload, seq=len(events))) + + _evt(RunEventType.MESSAGE, {"content": "Let me try to delete the root directory."}, 0.1) + _evt(RunEventType.TOOL_CALL, {"tool_name": "execute_command", "arguments": {"command": "rm -rf /"}}, 0.3) + _evt(RunEventType.TOOL_RESULT, { + "tool_name": "execute_command", + "tool_call_id": "tc-001", + "content": "Permission denied: cannot remove root directory", + "is_error": True, + "stderr": "rm: /: Permission denied", + "duration": 0.02, + }, 0.8) + _evt(RunEventType.FAILED, {"error_summary": "Permission denied — cannot execute destructive command"}, 1.0) + + return events + + +_FIXTURES = { + "happy-path": _fixture_happy_path, + "failed": _fixture_failed_path, +} + + +# --------------------------------------------------------------------------- +# CLI commands +# --------------------------------------------------------------------------- + + +def _run_fixture(scenario: str) -> RunResult: + """Load a fixture and run it through RunBridge.""" + factory = _FIXTURES.get(scenario) + if factory is None: + print(f"Unknown scenario: {scenario}. Available: {list(_FIXTURES.keys())}", file=sys.stderr) + sys.exit(1) + + events = factory() + bridge = RunBridge() + for event in events: + bridge.ingest(event) + return bridge.finalize() + + +async def cmd_accept(scenario: str = "all") -> None: + """Run acceptance tests for the main link.""" + scenarios = list(_FIXTURES.keys()) if scenario == "all" else [scenario] + + passed = 0 + failed = 0 + + for sc in scenarios: + result = _run_fixture(sc) + checks = _check_acceptance(result, sc) + for chk in checks: + if chk["ok"]: + passed += 1 + print(f" ✓ {chk['name']}") + else: + failed += 1 + print(f" ✗ {chk['name']}: {chk.get('detail', '')}") + + print(f"\nAcceptance: {passed} passed, {failed} failed") + if failed > 0: + sys.exit(1) + + +def _check_acceptance(result: RunResult, scenario: str) -> list: + """Run scenario-specific checks.""" + checks = [] + + if scenario == "happy-path": + checks.append({ + "name": "Run completed", + "ok": result.status == RunStatus.COMPLETED, + "detail": f"status={result.status.value}", + }) + checks.append({ + "name": "Message received", + "ok": len(result.message_parts) > 0, + "detail": f"{len(result.message_parts)} message parts", + }) + checks.append({ + "name": "Tool calls tracked", + "ok": len(result.tool_calls) >= 3, + "detail": f"{len(result.tool_calls)} tool calls", + }) + checks.append({ + "name": "Changed files extracted (from diff)", + "ok": len(result.changed_files) >= 3, + "detail": f"{len(result.changed_files)} files", + }) + checks.append({ + "name": "Changed files extracted (from tool output)", + "ok": len([cf for cf in result.changed_files + if any(cf.path.endswith(ext) for ext in ['.py', '.ts', '.js'])]) >= 2, + "detail": f"files: {[cf.path for cf in result.changed_files]}", + }) + checks.append({ + "name": "Diff patch available", + "ok": len(result.diff_patches) > 0, + "detail": f"{len(result.diff_patches)} diff patches", + }) + checks.append({ + "name": "Logs generated from tool output", + "ok": len(result.logs) > 0, + "detail": f"{len(result.logs)} log entries", + }) + checks.append({ + "name": "Reasoning blocks captured", + "ok": len(result.reasoning_blocks) > 0, + "detail": f"{len(result.reasoning_blocks)} reasoning blocks", + }) + elif scenario == "failed": + checks.append({ + "name": "Run failed", + "ok": result.status == RunStatus.FAILED, + "detail": f"status={result.status.value}", + }) + checks.append({ + "name": "Error summary captured", + "ok": result.error_summary is not None and len(result.error_summary) > 0, + "detail": f"error={result.error_summary[:50]}", + }) + checks.append({ + "name": "Tool call tracked with error", + "ok": any(tc.error for tc in result.tool_calls), + "detail": f"{sum(1 for tc in result.tool_calls if tc.error)} errors", + }) + + return checks + + +async def cmd_logs(scenario: str = "happy-path") -> None: + """Show aggregated logs from fixture events.""" + result = _run_fixture(scenario) + print(f"=== Logs ({len(result.logs)} entries) ===") + for log in result.logs: + level_marker = {"info": " ", "warn": "⚠", "error": "✗"}.get(log.level, " ") + print(f" [{log.tool}] {level_marker} {log.message[:120]}") + + print(f"\n=== Tool Calls ({len(result.tool_calls)}) ===") + for tc in result.tool_calls: + status_icon = "✗" if tc.error else "✓" + print(f" {status_icon} {tc.tool_name} ({tc.duration:.2f}s)") + if tc.output: + print(f" Output: {tc.output[:100]}") + + +async def cmd_changed_files(scenario: str = "happy-path") -> None: + """Show changed files from fixture events.""" + result = _run_fixture(scenario) + if not result.changed_files: + print("No files changed in this run.") + return + + print(f"=== Changed Files ({len(result.changed_files)}) ===") + for cf in result.changed_files: + icon = {"added": "A", "modified": "M", "deleted": "D"}.get(cf.status, "?") + plus = f"+{cf.additions}" if cf.additions > 0 else "" + minus = f"-{cf.deletions}" if cf.deletions > 0 else "" + print(f" {icon} {cf.path:<40} {plus:>5} {minus:>5}") + + +async def cmd_diff(scenario: str = "happy-path") -> None: + """Show unified diff from fixture events.""" + result = _run_fixture(scenario) + if not result.diff_patches: + print("No diff available for this run.") + return + + print(f"=== Diff ({len(result.diff_patches)} patches) ===") + for i, patch in enumerate(result.diff_patches): + if i > 0: + print("\n" + "=" * 60) + # Show first 40 lines per patch + lines = patch.split("\n") + shown = lines[:40] + if len(lines) > 40: + shown.append(f"... ({len(lines) - 40} more lines)") + print("\n".join(shown)) + + +# --------------------------------------------------------------------------- +# D1a-local stubs for review / QA reports +# +# These produce a ReviewReport / QAReport with the same fields consumed by +# cmd_ipc's JSON output below. They exist here so bridge_cli remains +# self-contained (no dependency on review_handler / review_cli / cli). +# The real implementations land in executors/review_handler in D1b and +# will replace these. +# --------------------------------------------------------------------------- + + +def _stub_review_report(main_run_id: str, diff_patch: str) -> ReviewReport: + """Heuristic stub review report (no executor invocation).""" + review_run_id = f"review-{main_run_id}-stub" + + findings: List[ReviewFinding] = [] + + if "password" in diff_patch.lower() or "secret" in diff_patch.lower() or "api_key" in diff_patch.lower(): + findings.append(ReviewFinding( + id=f"{review_run_id}-sec-1", + run_id=review_run_id, + severity=Severity.HIGH, + category=FindingCategory.SECURITY, + title="Potential hardcoded secret", + description="Diff contains references to 'password', 'secret', or 'api_key'. Verify these are not hardcoded credentials.", + suggestion="Use environment variables or a secrets manager.", + )) + + if "SELECT *" in diff_patch or 'f"' in diff_patch or "f'" in diff_patch: + findings.append(ReviewFinding( + id=f"{review_run_id}-sec-2", + run_id=review_run_id, + severity=Severity.CRITICAL, + category=FindingCategory.SECURITY, + title="Potential SQL injection or unsanitized string interpolation", + description="Diff contains f-strings or SELECT * patterns that may indicate unsanitized input.", + suggestion="Use parameterized queries or an ORM.", + )) + + new_files = [l for l in diff_patch.split("\n") if l.startswith("+++ ")] + test_files = [f for f in new_files if "test" in f.lower()] + if len(new_files) > len(test_files): + findings.append(ReviewFinding( + id=f"{review_run_id}-test-1", + run_id=review_run_id, + severity=Severity.MEDIUM, + category=FindingCategory.TEST_COVERAGE, + title="New files may lack test coverage", + description=f"{len(new_files)} files modified, only {len(test_files)} test files found.", + suggestion="Add tests for new functionality.", + )) + + if not findings: + findings.append(ReviewFinding( + id=f"{review_run_id}-info-1", + run_id=review_run_id, + severity=Severity.INFO, + category=FindingCategory.MAINTAINABILITY, + title="No obvious issues detected in diff", + description="Automated heuristic scan found no patterns of concern. Manual review still recommended.", + )) + + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + for f in findings: + severity_counts[f.severity.value] += 1 + + now = datetime.datetime.now(datetime.timezone.utc) + return ReviewReport( + review_run_id=review_run_id, + status=ReviewStatus.COMPLETED, + executor="stub", + total_findings=len(findings), + critical_count=severity_counts["critical"], + high_count=severity_counts["high"], + medium_count=severity_counts["medium"], + low_count=severity_counts["low"], + info_count=severity_counts["info"], + findings=findings, + started_at=now, + completed_at=now, + ) + + +def _stub_qa_report(main_run_id: str, changed_files: List[str]) -> QAReport: + """Heuristic stub QA report (no executor invocation).""" + now = datetime.datetime.now(datetime.timezone.utc) + return QAReport( + qa_run_id=f"qa-{main_run_id}-stub", + status=QAStatus.COMPLETED, + executor="stub", + test_passed=len(changed_files), + test_failed=0, + test_skipped=0, + test_output=f"Stub QA: verified {len(changed_files)} changed file(s) exist and are readable.", + risks=[], + started_at=now, + completed_at=now, + ) + + +async def cmd_ipc(action: str, **kwargs) -> None: + """Simulate IPC commands for Electron bridge testing.""" + if action == "trigger-review": + # Aggregate diff from fixture + bridge = RunBridge() + for e in _fixture_happy_path(): + bridge.ingest(e) + result = bridge.finalize() + diff_patch = "\n".join(result.diff_patches) if result.diff_patches else "" + # Run review via D1a-local stub + report = _stub_review_report("run-main-001", diff_patch) + print(json.dumps({ + "action": "review:trigger", + "run_id": report.review_run_id, + "status": report.status.value, + "total_findings": report.total_findings, + "critical": report.critical_count, + "high": report.high_count, + "medium": report.medium_count, + "low": report.low_count, + "executor": report.executor, + "findings": [ + {"severity": f.severity.value, "category": f.category.value, "title": f.title} + for f in report.findings[:5] + ], + "note": "Review run completed via stub heuristic (D1a-local stub, no executor required).", + }, indent=2)) + elif action == "trigger-qa": + bridge = RunBridge() + for e in _fixture_happy_path(): + bridge.ingest(e) + result = bridge.finalize() + changed_files = [cf.path for cf in result.changed_files] + report = _stub_qa_report("run-main-001", changed_files) + print(json.dumps({ + "action": "qa:trigger", + "run_id": report.qa_run_id, + "status": report.status.value, + "test_passed": report.test_passed, + "test_failed": report.test_failed, + "test_skipped": report.test_skipped, + "executor": report.executor, + "note": "QA run completed via stub (D1a-local stub, no executor required).", + }, indent=2)) + elif action == "continue": + thread_id = kwargs.get("thread_id", "thread-001") + print(json.dumps({ + "action": "run:continue", + "request": {"thread_id": thread_id, "prompt": "Fix the review findings", "previous_run_id": "run-happy-001"}, + "response": {"run_id": f"run-continue-{thread_id}"}, + "note": "New run created with conversation history from previous run.", + }, indent=2)) + elif action == "retry": + thread_id = kwargs.get("thread_id", "thread-001") + print(json.dumps({ + "action": "run:retry", + "request": {"thread_id": thread_id, "prompt": "Refactor auth module"}, + "response": {"run_id": f"run-retry-{thread_id}", "run_seq": 2}, + "note": "New run created WITHOUT history (fresh start).", + }, indent=2)) + else: + print(f"Unknown IPC action: {action}. Valid: trigger-review, trigger-qa, continue, retry", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def handle_bridge_command(args) -> None: + """Dispatch bridge subcommands.""" + sub = args.bridge_subcommand + if sub is None: + print("Usage: bridge {accept|logs|changed-files|diff|ipc} [...]", file=sys.stderr) + sys.exit(1) + + if sub == "accept": + await cmd_accept(scenario=getattr(args, "scenario", "all")) + elif sub == "logs": + await cmd_logs(scenario=getattr(args, "fixture", "happy-path")) + elif sub == "changed-files": + await cmd_changed_files(scenario=getattr(args, "fixture", "happy-path")) + elif sub == "diff": + await cmd_diff(scenario=getattr(args, "fixture", "happy-path")) + elif sub == "ipc": + await cmd_ipc( + action=getattr(args, "ipc_action", ""), + main_run_id=getattr(args, "main_run_id", "run-main-001"), + thread_id=getattr(args, "thread_id", "thread-001"), + ) + else: + print(f"Unknown bridge command: {sub}", file=sys.stderr) + sys.exit(1) diff --git a/executors/claude_code_adapter.py b/executors/claude_code_adapter.py new file mode 100644 index 000000000000..e43d20951b86 --- /dev/null +++ b/executors/claude_code_adapter.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +Claude Code adapter — runs ``claude-code`` as a subprocess. + +Minimal v0.3 implementation: + - Command launch via asyncio subprocess + - stdout/stderr log collection + - Exit status reporting + - Health check via ``which claude-code && claude-code --version`` + +Not implemented (v0.4+): + - Structured JSON line streaming (requires ``--output-format stream-json``) + - Diff generation (requires git snapshot tracking) + - Review gate + - Worktree support + +Status: **minimal** — start, stop, log collection, exit status, health. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterable, Dict, List, Optional + +from executors.types import ( + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunEventType, + RunStatus, + ExecutorHealthResult, + ExecutorHealthStatus, +) +from executors.health import ( + check_command_exists, + check_version, + make_unavailable_health, + make_available_health, +) + +logger = logging.getLogger(__name__) + + +# Default command name — can be overridden via ExecutorConfig.path +DEFAULT_COMMAND = "claude-code" +DEFAULT_VERSION_ARGS = "--version" + + +@dataclass +class _RunState: + run_id: str + process: Optional[asyncio.subprocess.Process] = None + status: RunStatus = RunStatus.PENDING + events: List[RunEvent] = field(default_factory=list) + error: Optional[str] = None + stdout_lines: List[str] = field(default_factory=list) + stderr_lines: List[str] = field(default_factory=list) + + +class ClaudeCodeAdapter: + """Runs Claude Code CLI as a subprocess.""" + + def __init__(self, workspace: Optional[Path] = None): + self._workspace = workspace or Path.cwd() + self._runs: Dict[str, _RunState] = {} + + # ------------------------------------------------------------------ + # Health + # ------------------------------------------------------------------ + + async def check_health(self) -> ExecutorHealthResult: + """Check that ``claude-code`` exists and is runnable.""" + command = _resolve_command(None) + found, path = await check_command_exists(command) + if not found: + return make_unavailable_health( + "claude-code", + f"Command not found: '{command}' — install via: npm install -g @anthropic-ai/claude-code", + ) + + version = await check_version(command, DEFAULT_VERSION_ARGS) + return make_available_health("claude-code", version=version) + + # ------------------------------------------------------------------ + # start + # ------------------------------------------------------------------ + + async def start( + self, run: AgentRun, config: ExecutorConfig + ) -> AdapterStartResult: + """Launch ``claude-code`` subprocess. + + The process receives the prompt via stdin and runs to completion. + stdout and stderr are collected as log events. + """ + run_id = run.id or str(uuid.uuid4()) + command = _resolve_command(config.path) + workspace = str(run.workspace) if run.workspace else str(self._workspace) + + state = _RunState(run_id=run_id, status=RunStatus.RUNNING) + self._runs[run_id] = state + + # Build command line + cmd = [command, "--print", "--dangerously-skip-permissions"] + if run.model_ref: + cmd.extend(["--model", run.model_ref]) + + try: + state.process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workspace, + env={**os.environ, **(run.env or {})}, + ) + + # Write prompt to stdin and close + if state.process.stdin: + state.process.stdin.write(run.prompt.encode("utf-8")) + await state.process.stdin.drain() + state.process.stdin.close() + + # Start background collection + asyncio.create_task( + self._collect_output(state), + name=f"claude-code-collect-{run_id[:8]}", + ) + + except FileNotFoundError: + state.status = RunStatus.FAILED + state.error = f"Command not found: {command}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + + # Get git snapshot + git_snapshot = await self._get_git_snapshot(workspace) + + return AdapterStartResult( + external_run_id=run_id, + base_path=workspace, + git_snapshot=git_snapshot, + ) + + async def _collect_output(self, state: _RunState) -> None: + """Read stdout/stderr until process exits, normalize to events.""" + if state.process is None: + return + + try: + # Read stdout and stderr concurrently + async def read_stream(stream, lines): + while True: + line = await stream.readline() + if not line: + break + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + lines.append(decoded) + self._append_event(state, RunEvent( + type=RunEventType.LOG, + payload={"line": decoded}, + )) + + await asyncio.gather( + read_stream(state.process.stdout, state.stdout_lines), + read_stream(state.process.stderr, state.stderr_lines), + ) + + await state.process.wait() + exit_code = state.process.returncode + + if exit_code == 0: + state.status = RunStatus.COMPLETED + self._append_event(state, RunEvent( + type=RunEventType.COMPLETED, + payload={"exit_code": exit_code}, + )) + else: + # Collect stderr summary + stderr_summary = "\n".join(state.stderr_lines[-20:]) + state.status = RunStatus.FAILED + state.error = f"Exit code {exit_code}: {stderr_summary}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={ + "exit_code": exit_code, + "error_summary": stderr_summary[:500], + }, + )) + + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": str(e)}, + )) + + # ------------------------------------------------------------------ + # stop + # ------------------------------------------------------------------ + + async def stop(self, run_id: str) -> None: + """Send SIGTERM to the subprocess. Idempotent.""" + state = self._runs.get(run_id) + if state is None or state.process is None: + return + if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED): + return + + try: + state.process.terminate() + try: + await asyncio.wait_for(state.process.wait(), timeout=10.0) + except asyncio.TimeoutError: + state.process.kill() + await state.process.wait() + except ProcessLookupError: + pass + + state.status = RunStatus.CANCELLED + + # ------------------------------------------------------------------ + # stream_events + # ------------------------------------------------------------------ + + async def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + """Yield buffered events from the subprocess run.""" + state = self._runs.get(run_id) + if state is None: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": f"Run {run_id} not found"}, + ) + return + + yielded = 0 + done_statuses = {RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED} + + while True: + while yielded < len(state.events): + yield state.events[yielded] + yielded += 1 + + if state.status in done_statuses: + if state.status == RunStatus.FAILED: + has_failed = any( + e.type == RunEventType.FAILED + for e in state.events + ) + if not has_failed: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error or "Unknown error"}, + ) + return + + await asyncio.sleep(0.1) + + # ------------------------------------------------------------------ + # get_status + # ------------------------------------------------------------------ + + async def get_status(self, run_id: str) -> RunStatus: + """Return current run status.""" + state = self._runs.get(run_id) + if state is None: + return RunStatus.FAILED + return state.status + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _append_event(state: _RunState, event: RunEvent) -> None: + event.seq = len(state.events) + event.timestamp = datetime.datetime.utcnow() + state.events.append(event) + + @staticmethod + async def _get_git_snapshot(workspace: str) -> Optional[str]: + try: + proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workspace, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0) + if proc.returncode == 0: + return stdout.decode().strip() + except Exception: + pass + return None + + +def _resolve_command(override: Optional[str]) -> str: + """Resolve the command path. If override is given, use it; else default.""" + if override: + return override + # Check for env override + env_cmd = os.environ.get("HERMES_CLAUDE_CODE_PATH") + if env_cmd: + return env_cmd + return DEFAULT_COMMAND diff --git a/executors/cli.py b/executors/cli.py new file mode 100644 index 000000000000..7f7bb2e3efa7 --- /dev/null +++ b/executors/cli.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +""" +CLI for executor and worktree management. + +Usage: + python -m executors.cli list # list all registered executors + python -m executors.cli health # run health checks on all + python -m executors.cli health --json # machine-readable output + python -m executors.cli info # show detailed info for one executor + + python -m executors.cli worktree create [--run-seq N] + python -m executors.cli worktree status + python -m executors.cli worktree merge + python -m executors.cli worktree discard [--force] + python -m executors.cli worktree list [--all] + python -m executors.cli worktree diff + python -m executors.cli worktree files + +This module also provides ``create_default_registry()``, the canonical +factory function used by the main CLI and UI. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +import datetime +from typing import Optional, Dict + +from executors.types import ExecutorId, ExecutorHealthStatus +from executors.registry import ExecutorRegistry, _default_manifests +from executors.health import check_all_executors_health +from executors.hermes_local_adapter import HermesLocalAdapter +from executors.claude_code_adapter import ClaudeCodeAdapter +from executors.codex_adapter import CodexAdapter +from executors.deepseek_tui_adapter import DeepSeekTuiAdapter +from executors.opencode_adapter import OpenCodeAdapter +from executors.worktree_cli import handle_worktree_command +from executors.context_cli import handle_context_command +from executors.review_cli import handle_review_command, handle_qa_command +from executors.inbox_cli import handle_inbox_command +from executors.bridge_cli import handle_bridge_command + + +# --------------------------------------------------------------------------- +# Registry factory +# --------------------------------------------------------------------------- + +def create_default_registry() -> ExecutorRegistry: + """Create and populate an ExecutorRegistry with all four adapters.""" + registry = ExecutorRegistry() + manifests = _default_manifests() + + registry.register(manifests["hermes-local"], HermesLocalAdapter()) + registry.register(manifests["claude-code"], ClaudeCodeAdapter()) + registry.register(manifests["codex-cli"], CodexAdapter()) + registry.register(manifests["deepseek-tui"], DeepSeekTuiAdapter()) + registry.register(manifests["opencode"], OpenCodeAdapter()) + + return registry + + +# --------------------------------------------------------------------------- +# Formatters +# --------------------------------------------------------------------------- + +STATUS_ICONS = { + ExecutorHealthStatus.AVAILABLE: "✅", + ExecutorHealthStatus.UNAVAILABLE: "❌", + ExecutorHealthStatus.UNKNOWN: "❓", +} + + +def _format_health_table(registry: ExecutorRegistry) -> str: + lines = [] + lines.append(f"{'ID':<16} {'Status':<14} {'Version':<30} {'Error'}") + lines.append("-" * 100) + + for m in registry.list_executors(): + h = registry.get_health(m.id) + icon = STATUS_ICONS.get(h.status, "?") + version = h.version or "-" + error = h.error or "-" + if len(error) > 50: + error = error[:47] + "..." + lines.append( + f"{m.id:<16} {icon} {h.status.value:<11} " + f"{version:<30} {error}" + ) + return "\n".join(lines) + + +def _format_health_json(registry: ExecutorRegistry) -> str: + result = {} + for m in registry.list_executors(): + h = registry.get_health(m.id) + result[m.id] = { + "status": h.status.value, + "version": h.version, + "error": h.error, + "capabilities": { + "structured_tool_calls": m.capabilities.structured_tool_calls, + "native_diff_events": m.capabilities.native_diff_events, + "reasoning_blocks": m.capabilities.reasoning_blocks, + "review_gate": m.capabilities.review_gate, + "streaming": m.capabilities.streaming, + }, + "ui_fidelity": m.ui_fidelity, + "label": m.label, + "description": m.description, + } + return json.dumps(result, indent=2, ensure_ascii=False) + + +def _format_executor_info(executor_id: ExecutorId, registry: ExecutorRegistry) -> str: + try: + m = registry.get_manifest(executor_id) + except KeyError: + return f"Unknown executor: {executor_id}" + + h = registry.get_health(executor_id) + icon = STATUS_ICONS.get(h.status, "?") + + return "\n".join([ + f"ID: {m.id}", + f"Label: {m.label}", + f"Description: {m.description}", + f"Health: {icon} {h.status.value}", + f"Version: {h.version or 'N/A'}", + f"Error: {h.error or 'None'}", + f"Fidelity: {m.ui_fidelity}", + f"Default Model:{m.default_model or 'N/A'}", + f"Worktree: {'supported' if m.supports_worktree else 'unsupported'}", + f"Capabilities:", + f" structured_tool_calls: {m.capabilities.structured_tool_calls}", + f" native_diff_events: {m.capabilities.native_diff_events}", + f" reasoning_blocks: {m.capabilities.reasoning_blocks}", + f" review_gate: {m.capabilities.review_gate}", + f" streaming: {m.capabilities.streaming}", + ]) + + +async def _cmd_list(registry: ExecutorRegistry, json_output: bool) -> None: + if json_output: + data = [{"id": m.id, "label": m.label} for m in registry.list_executors()] + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + for m in registry.list_executors(): + h = registry.get_health(m.id) + icon = STATUS_ICONS.get(h.status, "?") + print(f" {icon} {m.id:<16} {m.label:<20} {h.status.value}") + + +async def _cmd_health(registry: ExecutorRegistry, json_output: bool) -> None: + print("Running health checks...", flush=True) + await check_all_executors_health(registry) + print() + if json_output: + print(_format_health_json(registry)) + else: + print(_format_health_table(registry)) + + +async def _cmd_info(registry: ExecutorRegistry, executor_id: str) -> None: + await check_all_executors_health(registry) + print(_format_executor_info(executor_id, registry)) + + +async def _cmd_select(registry: ExecutorRegistry, executor_id: Optional[str]) -> None: + """Interactive executor selector — show all with health status, let user pick.""" + await check_all_executors_health(registry) + + if executor_id: + try: + m = registry.get_manifest(executor_id) + except KeyError: + print(f"Unknown executor: {executor_id}", file=sys.stderr) + print(f"Available: {', '.join(m.id for m in registry.list_executors())}", file=sys.stderr) + sys.exit(1) + + h = registry.get_health(executor_id) + icon = STATUS_ICONS.get(h.status, "?") + print(f"Selected: {icon} {m.id} ({m.label})") + + if h.status != ExecutorHealthStatus.AVAILABLE: + print(f"\nWARNING: {m.label} is not available.") + print(f" Reason: {h.error or 'Unknown'}") + print(f" Suggestion: use 'hermes-local' instead.") + sys.exit(1) + + print(f" Model: {m.default_model or 'N/A'}") + print(f" Status: {h.status.value}") + print(f" Version: {h.version or 'N/A'}") + print(f" Worktree: {'yes' if m.supports_worktree else 'no'}") + print("Ready to use.") + else: + manifests = registry.list_executors() + print("Select an executor:\n") + for i, m in enumerate(manifests, 1): + h = registry.get_health(m.id) + icon = STATUS_ICONS.get(h.status, "?") + if h.status == ExecutorHealthStatus.AVAILABLE: + tag = "" + elif h.status == ExecutorHealthStatus.UNAVAILABLE: + tag = f" (unavailable: {h.error[:60]})" + else: + tag = " (health unknown)" + print(f" {i}. {icon} {m.id:<16} {m.label:<20} [{m.ui_fidelity}]{tag}") + print(f"\n {len(manifests) + 1}. Cancel") + print("\nUse: hermes run --executor ...") + sys.exit(0) + + +async def _cmd_route(registry: ExecutorRegistry, args) -> None: + """Route a task to a recommended executor, with user confirmation.""" + from executors.router import create_default_router + from executors.types import TaskCreateContext + + await check_all_executors_health(registry) + + # Build context + available_ids = [m.id for m in registry.list_available()] + ctx = TaskCreateContext( + title=args.title, + goal=args.goal or "", + available_executors=available_ids, + ) + + router = create_default_router() + rec = router.route(ctx) + + # Print health overview + print("Executor Health:") + for m in registry.list_executors(): + h = registry.get_health(m.id) + icon = STATUS_ICONS.get(h.status, "?") + print(f" {icon} {m.id:<16} ({h.status.value})") + + # Print recommendation + print(f"\nRouting for: {args.title}") + if args.goal: + print(f" Goal: {args.goal[:120]}") + print(f"\n Recommended: {rec.recommended_executor}") + print(f" Confidence: {rec.confidence:.0%}") + print(f" Reason: {rec.reason}") + if rec.alternatives: + print(f" Alternatives: {', '.join(rec.alternatives)}") + + # User override + if args.executor: + print(f"\n User override: {args.executor}") + rec.recommended_executor = args.executor + rec.override = True + rec.source = "manual" + elif args.accept: + print(f"\n Auto-accepted: {args.accept}") + rec.override = False + else: + # Interactive confirmation + try: + resp = input(f"\nAccept? [{rec.recommended_executor}/No/manual]: ").strip() + if not resp or resp.lower() in ("y", "yes", ""): + print(f" Accepted: {rec.recommended_executor}") + elif resp.lower() in ("n", "no"): + print(" Declined. No executor selected.") + rec.recommended_executor = "" + rec.confidence = 0.0 + else: + # Manual selection + rec.recommended_executor = resp + rec.override = True + rec.source = "manual" + print(f" Manual selection: {resp}") + except (KeyboardInterrupt, EOFError): + print("\n Cancelled.") + return + + # Output final selection as JSON for tooling + import json + result = { + "executor": rec.recommended_executor, + "confidence": rec.confidence, + "reason": rec.reason, + "source": rec.source, + "override": rec.override, + "route_timestamp": str(datetime.datetime.utcnow()), + } + print(f"\n Result: {json.dumps(result, indent=2)}") + + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main(): + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser( + prog="executors", + description="Hermes executor and worktree management CLI", + ) + parser.add_argument( + "--project-root", "-C", + default=".", + help="Project root directory (default: current directory)", + ) + sub = parser.add_subparsers(dest="command") + + list_p = sub.add_parser("list", help="List all registered executors") + list_p.add_argument("--json", action="store_true", help="JSON output") + + health_p = sub.add_parser("health", help="Run health checks on all executors") + health_p.add_argument("--json", action="store_true", help="JSON output") + + info_p = sub.add_parser("info", help="Show detailed info for one executor") + info_p.add_argument("executor_id", help="Executor ID") + + select_p = sub.add_parser("select", help="Select an executor (interactive or by id)") + select_p.add_argument("executor_id", nargs="?", help="Executor ID (skip for interactive)") + + # Router subcommand + route_p = sub.add_parser("route", help="Recommend an executor for a task") + route_p.add_argument("title", help="Task title") + route_p.add_argument("--goal", "-g", default="", help="Task goal/description") + route_p.add_argument("--accept", "-a", help="Auto-accept the recommendation (skip user confirmation)") + route_p.add_argument("--executor", "-e", help="Override executor selection (user choice)") + + # Worktree subcommands + wt_p = sub.add_parser("worktree", help="Worktree management") + wt_sub = wt_p.add_subparsers(dest="worktree_subcommand") + + wt_create = wt_sub.add_parser("create", help="Create a worktree for a thread") + wt_create.add_argument("thread_id", help="Task thread ID") + wt_create.add_argument("--run-seq", type=int, default=1, help="Run sequence number (default: 1)") + + wt_status = wt_sub.add_parser("status", help="Show worktree status") + wt_status.add_argument("thread_id", help="Task thread ID") + + wt_merge = wt_sub.add_parser("merge", help="Merge worktree into main repo") + wt_merge.add_argument("thread_id", help="Task thread ID") + + wt_discard = wt_sub.add_parser("discard", help="Discard worktree changes") + wt_discard.add_argument("thread_id", help="Task thread ID") + wt_discard.add_argument("--force", action="store_true", help="Skip confirmation prompt") + + wt_list = wt_sub.add_parser("list", help="List all worktrees") + wt_list.add_argument("--all", action="store_true", help="Include released worktrees") + + wt_diff = wt_sub.add_parser("diff", help="Show diff for a worktree") + wt_diff.add_argument("thread_id", help="Task thread ID") + + wt_files = wt_sub.add_parser("files", help="List changed files in worktree") + wt_files.add_argument("thread_id", help="Task thread ID") + + # Context subcommands + ctx_p = sub.add_parser("context", help="Project context management") + ctx_sub = ctx_p.add_subparsers(dest="context_subcommand") + + ctx_show = ctx_sub.add_parser("show", help="Show project context") + ctx_show.add_argument("--json", action="store_true", help="JSON output") + + ctx_edit = ctx_sub.add_parser("edit", help="Edit a context field") + ctx_edit.add_argument("--field", "-f", required=True, choices=["overview","architecture","sprint","conventions"]) + ctx_edit.add_argument("--value", "-v", required=True, help="New value") + + ctx_adr = ctx_sub.add_parser("adr", help="Manage ADR summaries") + ctx_adr.add_argument("adr_action", choices=["add", "remove"]) + ctx_adr.add_argument("adr_args", nargs="*") + + ctx_cmd = ctx_sub.add_parser("cmd", help="Manage common commands") + ctx_cmd.add_argument("cmd_action", choices=["add", "remove"]) + ctx_cmd.add_argument("cmd_args", nargs="*") + + ctx_forbidden = ctx_sub.add_parser("forbidden", help="Manage forbidden areas") + ctx_forbidden.add_argument("forbidden_action", choices=["add", "remove"]) + ctx_forbidden.add_argument("forbidden_args", nargs="*") + + ctx_inj = ctx_sub.add_parser("injection", help="Enable/disable context injection") + ctx_inj.add_argument("injection_action", choices=["on", "off", "enable", "disable"]) + + ctx_preview = ctx_sub.add_parser("preview", help="Preview context injection for an executor") + ctx_preview.add_argument("executor_id", help="Executor ID") + ctx_preview.add_argument("--goal", "-g", default="", help="Task goal for full preview") + + # Review subcommands + rev_p = sub.add_parser("review", help="Review agent commands") + rev_sub = rev_p.add_subparsers(dest="review_subcommand") + + rev_build = rev_sub.add_parser("build-prompt", help="Build a review prompt") + rev_build.add_argument("--goal", "-g", default="", help="Task goal") + rev_build.add_argument("--diff", "-d", default="", help="Git diff output") + rev_build.add_argument("--changed-files", "-f", default="", help="Comma-separated changed files") + rev_build.add_argument("--executor", "-e", default="claude-code", help="Main run executor") + rev_build.add_argument("--prompt-snapshot", "-p", default="", help="Main run prompt snapshot") + + rev_parse = rev_sub.add_parser("parse", help="Parse review findings from output") + rev_parse.add_argument("--review-run-id", default="review-test-001", help="Review run ID") + rev_parse.add_argument("--input", "-i", default="", help="Executor output text") + rev_parse.add_argument("--input-file", default="", help="File containing executor output") + + rev_exec = rev_sub.add_parser("executor", help="Recommend review executor") + rev_exec.add_argument("--available", "-a", default="", help="Comma-separated available executor IDs") + + # QA subcommands + qa_p = sub.add_parser("qa", help="QA agent commands") + qa_sub = qa_p.add_subparsers(dest="qa_subcommand") + + qa_build = qa_sub.add_parser("build-prompt", help="Build a QA prompt") + qa_build.add_argument("--goal", "-g", default="", help="Task goal") + qa_build.add_argument("--changed-files", "-f", default="", help="Comma-separated changed files") + qa_build.add_argument("--test-cmds", "-t", default="", help="Semicolon-separated label:command pairs") + qa_build.add_argument("--worktree-path", "-w", default="", help="Worktree path") + + qa_parse = qa_sub.add_parser("parse", help="Parse QA results from output") + qa_parse.add_argument("--qa-run-id", default="qa-test-001", help="QA run ID") + qa_parse.add_argument("--input", "-i", default="", help="Executor output text") + qa_parse.add_argument("--input-file", default="", help="File containing executor output") + + qa_exec = qa_sub.add_parser("executor", help="Recommend QA executor") + qa_exec.add_argument("--available", "-a", default="", help="Comma-separated available executor IDs") + + # Inbox subcommands + ib_p = sub.add_parser("inbox", help="External inbox management") + ib_sub = ib_p.add_subparsers(dest="inbox_subcommand") + + ib_add = ib_sub.add_parser("add", help="Add an inbox item") + ib_add.add_argument("--source", "-s", required=True, choices=[s.value for s in __import__('executors.types', fromlist=['InboxSource']).InboxSource]) + ib_add.add_argument("--title", "-t", required=True, help="Task title") + ib_add.add_argument("--body", "-b", required=True, help="Task body/prompt") + ib_add.add_argument("--executor", "-e", default="", help="Suggested executor") + ib_add.add_argument("--project", "-p", default="", help="Project hint") + ib_add.add_argument("--priority", default="normal", choices=["high", "normal", "low"]) + + ib_list = ib_sub.add_parser("list", help="List inbox items") + ib_list.add_argument("--status", default="", choices=["", "pending", "confirmed", "rejected", "archived", "expired"]) + ib_list.add_argument("--source", default="") + ib_list.add_argument("--json", action="store_true") + + ib_show = ib_sub.add_parser("show", help="Show inbox item details") + ib_show.add_argument("item_id") + ib_show.add_argument("--json", action="store_true") + + ib_convert = ib_sub.add_parser("convert", help="Convert to task thread") + ib_convert.add_argument("item_id") + ib_convert.add_argument("--task-id", required=True, help="Target task thread ID") + + ib_reject = ib_sub.add_parser("reject", help="Reject an inbox item") + ib_reject.add_argument("item_id") + ib_reject.add_argument("--reason", "-r", default="", help="Rejection reason") + + ib_archive = ib_sub.add_parser("archive", help="Archive an inbox item") + ib_archive.add_argument("item_id") + + ib_edit = ib_sub.add_parser("edit", help="Edit task draft") + ib_edit.add_argument("item_id") + ib_edit.add_argument("--title", default="") + ib_edit.add_argument("--prompt", default="") + ib_edit.add_argument("--executor", default="") + ib_edit.add_argument("--project", default="") + ib_edit.add_argument("--priority", default="", choices=["", "high", "normal", "low"]) + + ib_summary = ib_sub.add_parser("summary", help="Show inbox summary") + + # Bridge subcommands (v1.0 acceptance path) + br_p = sub.add_parser("bridge", help="v1.0 bridge acceptance and IPC testing") + br_sub = br_p.add_subparsers(dest="bridge_subcommand") + + br_accept = br_sub.add_parser("accept", help="Run acceptance tests") + br_accept.add_argument("--scenario", "-s", default="all", choices=["happy-path", "failed", "all"]) + + br_logs = br_sub.add_parser("logs", help="Show aggregated logs") + br_logs.add_argument("--fixture", "-f", default="happy-path", choices=["happy-path", "failed"]) + + br_files = br_sub.add_parser("changed-files", help="Show changed files") + br_files.add_argument("--fixture", "-f", default="happy-path", choices=["happy-path", "failed"]) + + br_diff = br_sub.add_parser("diff", help="Show unified diff") + br_diff.add_argument("--fixture", "-f", default="happy-path", choices=["happy-path", "failed"]) + + br_ipc = br_sub.add_parser("ipc", help="Simulate IPC commands") + br_ipc_sub = br_ipc.add_subparsers(dest="ipc_action") + br_ipc_sub.add_parser("trigger-review", help="Simulate triggerReview IPC") + br_ipc_sub.add_parser("trigger-qa", help="Simulate triggerQA IPC") + br_ipc_sub.add_parser("continue", help="Simulate continueRun IPC") + br_ipc_sub.add_parser("retry", help="Simulate retryRun IPC") + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + sys.exit(1) + + async def run(): + if args.command == "worktree": + project_root = Path(args.project_root).resolve() + await handle_worktree_command(project_root, args) + return + + registry = create_default_registry() + json_out = getattr(args, "json", False) + + if args.command == "list": + await _cmd_list(registry, json_out) + elif args.command == "health": + await _cmd_health(registry, json_out) + elif args.command == "info": + await _cmd_info(registry, args.executor_id) + elif args.command == "select": + await _cmd_select(registry, getattr(args, "executor_id", None)) + elif args.command == "route": + await _cmd_route(registry, args) + elif args.command == "context": + project_root = Path(args.project_root).resolve() + await handle_context_command(project_root, args) + elif args.command == "review": + await handle_review_command(args) + elif args.command == "qa": + await handle_qa_command(args) + elif args.command == "inbox": + project_root = Path(args.project_root).resolve() + await handle_inbox_command(project_root, args) + elif args.command == "bridge": + await handle_bridge_command(args) + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/executors/codex_adapter.py b/executors/codex_adapter.py new file mode 100644 index 000000000000..197c05f0145c --- /dev/null +++ b/executors/codex_adapter.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Codex adapter — runs ``codex`` CLI as a subprocess. + +Minimal v0.3 implementation: + - Command launch via asyncio subprocess + - stdout/stderr log collection + - Exit status reporting + - Health check via ``which codex && codex --version`` + + If ``codex`` is not available: health reports ``UNAVAILABLE``, but the + adapter still registers so the UI can show it as disabled with a reason. + +Not implemented (v0.4+): + - Cloud API mode (currently CLI subprocess only) + - Structured JSON output parsing + - Diff generation + - Worktree support + +Status: **minimal** — start, stop, log collection, exit status, health. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterable, Dict, List, Optional + +from executors.types import ( + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunEventType, + RunStatus, + ExecutorHealthResult, + ExecutorHealthStatus, +) +from executors.health import ( + check_command_exists, + check_version, + make_unavailable_health, + make_available_health, +) + +logger = logging.getLogger(__name__) + +DEFAULT_COMMAND = "codex" +DEFAULT_VERSION_ARGS = "--version" + + +@dataclass +class _RunState: + run_id: str + process: Optional[asyncio.subprocess.Process] = None + status: RunStatus = RunStatus.PENDING + events: List[RunEvent] = field(default_factory=list) + error: Optional[str] = None + stdout_lines: List[str] = field(default_factory=list) + stderr_lines: List[str] = field(default_factory=list) + + +class CodexAdapter: + """Runs Codex CLI as a subprocess. + + If ``codex`` is not installed, ``check_health()`` reports UNAVAILABLE + and the adapter cannot start runs. + """ + + def __init__(self, workspace: Optional[Path] = None): + self._workspace = workspace or Path.cwd() + self._runs: Dict[str, _RunState] = {} + + # ------------------------------------------------------------------ + # Health + # ------------------------------------------------------------------ + + async def check_health(self) -> ExecutorHealthResult: + """Check that ``codex`` exists and is runnable.""" + command = _resolve_command(None) + found, path = await check_command_exists(command) + if not found: + return make_unavailable_health( + "codex", + f"Command not found: '{command}' — " + f"install via Codex.app or set HERMES_CODEX_PATH", + ) + + version = await check_version(command, DEFAULT_VERSION_ARGS) + return make_available_health("codex", version=version) + + # ------------------------------------------------------------------ + # start + # ------------------------------------------------------------------ + + async def start( + self, run: AgentRun, config: ExecutorConfig + ) -> AdapterStartResult: + """Launch ``codex`` subprocess with the prompt. + + The command format is: ``codex -p ""`` + """ + run_id = run.id or str(uuid.uuid4()) + command = _resolve_command(config.path) + workspace = str(run.workspace) if run.workspace else str(self._workspace) + + state = _RunState(run_id=run_id, status=RunStatus.RUNNING) + self._runs[run_id] = state + + # Codex CLI accepts prompts with -p flag + cmd = [command, "-p", run.prompt] + + try: + state.process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workspace, + env={**os.environ, **(run.env or {})}, + ) + + asyncio.create_task( + self._collect_output(state), + name=f"codex-collect-{run_id[:8]}", + ) + + except FileNotFoundError: + state.status = RunStatus.FAILED + state.error = f"Command not found: {command}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + + git_snapshot = await self._get_git_snapshot(workspace) + + return AdapterStartResult( + external_run_id=run_id, + base_path=workspace, + git_snapshot=git_snapshot, + ) + + async def _collect_output(self, state: _RunState) -> None: + """Read stdout/stderr, normalize to log events.""" + if state.process is None: + return + + try: + async def read_stream(stream, lines): + while True: + line = await stream.readline() + if not line: + break + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + lines.append(decoded) + self._append_event(state, RunEvent( + type=RunEventType.LOG, + payload={"line": decoded}, + )) + + await asyncio.gather( + read_stream(state.process.stdout, state.stdout_lines), + read_stream(state.process.stderr, state.stderr_lines), + ) + + await state.process.wait() + exit_code = state.process.returncode + + if exit_code == 0: + state.status = RunStatus.COMPLETED + self._append_event(state, RunEvent( + type=RunEventType.COMPLETED, + payload={"exit_code": exit_code}, + )) + else: + stderr_summary = "\n".join(state.stderr_lines[-20:]) + state.status = RunStatus.FAILED + state.error = f"Exit code {exit_code}: {stderr_summary}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={ + "exit_code": exit_code, + "error_summary": stderr_summary[:500], + }, + )) + + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": str(e)}, + )) + + # ------------------------------------------------------------------ + # stop + # ------------------------------------------------------------------ + + async def stop(self, run_id: str) -> None: + """Send SIGTERM to subprocess. Idempotent.""" + state = self._runs.get(run_id) + if state is None or state.process is None: + return + if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED): + return + + try: + state.process.terminate() + try: + await asyncio.wait_for(state.process.wait(), timeout=10.0) + except asyncio.TimeoutError: + state.process.kill() + await state.process.wait() + except ProcessLookupError: + pass + + state.status = RunStatus.CANCELLED + + # ------------------------------------------------------------------ + # stream_events / get_status (identical pattern to ClaudeCodeAdapter) + # ------------------------------------------------------------------ + + async def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + state = self._runs.get(run_id) + if state is None: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": f"Run {run_id} not found"}, + ) + return + + yielded = 0 + done_statuses = {RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED} + + while True: + while yielded < len(state.events): + yield state.events[yielded] + yielded += 1 + + if state.status in done_statuses: + if state.status == RunStatus.FAILED: + has_failed = any( + e.type == RunEventType.FAILED + for e in state.events + ) + if not has_failed: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error or "Unknown error"}, + ) + return + + await asyncio.sleep(0.1) + + async def get_status(self, run_id: str) -> RunStatus: + state = self._runs.get(run_id) + if state is None: + return RunStatus.FAILED + return state.status + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _append_event(state: _RunState, event: RunEvent) -> None: + event.seq = len(state.events) + event.timestamp = datetime.datetime.utcnow() + state.events.append(event) + + @staticmethod + async def _get_git_snapshot(workspace: str) -> Optional[str]: + try: + proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workspace, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0) + if proc.returncode == 0: + return stdout.decode().strip() + except Exception: + pass + return None + + +def _resolve_command(override: Optional[str]) -> str: + if override: + return override + env_cmd = os.environ.get("HERMES_CODEX_PATH") + if env_cmd: + return env_cmd + return DEFAULT_COMMAND diff --git a/executors/context.py b/executors/context.py new file mode 100644 index 000000000000..ace4ef7d4e2b --- /dev/null +++ b/executors/context.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +WorkspaceContextManager — CRUD for project context stored at .hermes/context.json. + +Follows the v0.6 workspace-context-injection.md spec: + - Persists to JSON (not YAML, to avoid yaml dependency) + - Supports all 9 context fields + injection control + - recent_tasks auto-capped at 10 entries + - Hash-based integrity check for prompt snapshots +""" + +from __future__ import annotations + +import datetime +import hashlib +import json +import logging +import os +from copy import deepcopy +from dataclasses import asdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +from executors.types import ( + AdrSummary, + CommandEntry, + ProjectContext, + RecentTask, +) + +logger = logging.getLogger(__name__) + +CONTEXT_FILENAME = "context.json" +MAX_RECENT_TASKS = 10 + + +class WorkspaceContextManager: + """Manages project context at ``/.hermes/context.json``.""" + + def __init__(self, project_root: Path): + self._project_root = Path(project_root).resolve() + self._context_dir = self._project_root / ".hermes" + self._context_path = self._context_dir / CONTEXT_FILENAME + self._context: Optional[ProjectContext] = None + + # ------------------------------------------------------------------ + # Load / Save + # ------------------------------------------------------------------ + + def load(self) -> ProjectContext: + """Load context from disk, or return default if file doesn't exist.""" + if self._context is not None: + return self._context + + if self._context_path.exists(): + try: + raw = json.loads(self._context_path.read_text()) + self._context = self._from_dict(raw) + logger.debug("Loaded context from %s", self._context_path) + return self._context + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning("Corrupt context.json, using defaults: %s", e) + + self._context = ProjectContext() + return self._context + + def save(self) -> None: + """Persist current context to disk.""" + if self._context is None: + self._context = ProjectContext() + + self._context_dir.mkdir(parents=True, exist_ok=True) + + data = self._to_dict(self._context) + self._context_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False, default=str) + ) + logger.debug("Saved context to %s", self._context_path) + + def is_loaded(self) -> bool: + """Check if context has been loaded.""" + return self._context is not None + + # ------------------------------------------------------------------ + # Full get/set + # ------------------------------------------------------------------ + + def get_context(self) -> ProjectContext: + """Get the full project context (loads if needed).""" + return self.load() + + def set_context(self, ctx: ProjectContext) -> None: + """Replace the entire context and save.""" + self._context = ctx + self.save() + + # ------------------------------------------------------------------ + # Field-level getters / setters + # ------------------------------------------------------------------ + + def get_overview(self) -> str: + return self.load().project_overview + + def set_overview(self, text: str) -> None: + ctx = self.load() + ctx.project_overview = text + self.save() + + def get_architecture(self) -> str: + return self.load().architecture_notes + + def set_architecture(self, text: str) -> None: + ctx = self.load() + ctx.architecture_notes = text + self.save() + + def get_adrs(self) -> List[AdrSummary]: + return self.load().adr_summaries + + def add_adr(self, adr_id: str, title: str, decision: str) -> None: + ctx = self.load() + ctx.adr_summaries.append(AdrSummary(id=adr_id, title=title, decision=decision)) + self.save() + + def remove_adr(self, adr_id: str) -> None: + ctx = self.load() + ctx.adr_summaries = [a for a in ctx.adr_summaries if a.id != adr_id] + self.save() + + def get_sprint(self) -> str: + return self.load().current_sprint + + def set_sprint(self, text: str) -> None: + ctx = self.load() + ctx.current_sprint = text + self.save() + + def get_common_commands(self) -> List[CommandEntry]: + return self.load().common_commands + + def add_common_command(self, label: str, command: str) -> None: + ctx = self.load() + ctx.common_commands.append(CommandEntry(label=label, command=command)) + self.save() + + def remove_common_command(self, label: str) -> None: + ctx = self.load() + ctx.common_commands = [c for c in ctx.common_commands if c.label != label] + self.save() + + def get_test_commands(self) -> List[CommandEntry]: + return self.load().test_commands + + def add_test_command(self, label: str, command: str) -> None: + ctx = self.load() + ctx.test_commands.append(CommandEntry(label=label, command=command)) + self.save() + + def get_forbidden_areas(self) -> List[str]: + return self.load().forbidden_areas + + def add_forbidden_area(self, path: str) -> None: + ctx = self.load() + if path not in ctx.forbidden_areas: + ctx.forbidden_areas.append(path) + self.save() + + def remove_forbidden_area(self, path: str) -> None: + ctx = self.load() + ctx.forbidden_areas = [p for p in ctx.forbidden_areas if p != path] + self.save() + + def get_conventions(self) -> str: + return self.load().coding_conventions + + def set_conventions(self, text: str) -> None: + ctx = self.load() + ctx.coding_conventions = text + self.save() + + def get_recent_tasks(self) -> List[RecentTask]: + return self.load().recent_tasks + + def add_recent_task(self, task: RecentTask) -> None: + """Append a recent task, capping at MAX_RECENT_TASKS.""" + ctx = self.load() + ctx.recent_tasks.append(task) + if len(ctx.recent_tasks) > MAX_RECENT_TASKS: + ctx.recent_tasks = ctx.recent_tasks[-MAX_RECENT_TASKS:] + self.save() + + def get_injection_enabled(self) -> bool: + return self.load().context_injection_enabled + + def set_injection_enabled(self, enabled: bool) -> None: + ctx = self.load() + ctx.context_injection_enabled = enabled + self.save() + + # ------------------------------------------------------------------ + # Hash + # ------------------------------------------------------------------ + + def context_hash(self) -> str: + """SHA-256 hash of the current context, for snapshot integrity.""" + ctx = self.load() + raw = json.dumps(self._to_dict(ctx), sort_keys=True, ensure_ascii=False) + return hashlib.sha256(raw.encode()).hexdigest()[:16] + + # ------------------------------------------------------------------ + # Serialization helpers + # ------------------------------------------------------------------ + + @staticmethod + def _to_dict(ctx: ProjectContext) -> Dict[str, Any]: + return { + "project_overview": ctx.project_overview, + "architecture_notes": ctx.architecture_notes, + "adr_summaries": [asdict(a) for a in ctx.adr_summaries], + "current_sprint": ctx.current_sprint, + "common_commands": [asdict(c) for c in ctx.common_commands], + "test_commands": [asdict(c) for c in ctx.test_commands], + "forbidden_areas": ctx.forbidden_areas, + "coding_conventions": ctx.coding_conventions, + "recent_tasks": [asdict(t) for t in ctx.recent_tasks], + "context_injection_enabled": ctx.context_injection_enabled, + } + + @staticmethod + def _from_dict(raw: Dict[str, Any]) -> ProjectContext: + return ProjectContext( + project_overview=raw.get("project_overview", ""), + architecture_notes=raw.get("architecture_notes", ""), + adr_summaries=[ + AdrSummary(**a) for a in raw.get("adr_summaries", []) + ], + current_sprint=raw.get("current_sprint", ""), + common_commands=[ + CommandEntry(**c) for c in raw.get("common_commands", []) + ], + test_commands=[ + CommandEntry(**c) for c in raw.get("test_commands", []) + ], + forbidden_areas=raw.get("forbidden_areas", []), + coding_conventions=raw.get("coding_conventions", ""), + recent_tasks=[ + RecentTask(**t) for t in raw.get("recent_tasks", []) + ], + context_injection_enabled=raw.get("context_injection_enabled", True), + ) + + +# --------------------------------------------------------------------------- +# Convenience factory +# --------------------------------------------------------------------------- + +def create_context_manager(project_root: Path) -> WorkspaceContextManager: + return WorkspaceContextManager(project_root) diff --git a/executors/context_cli.py b/executors/context_cli.py new file mode 100644 index 000000000000..82adb8f6b1fa --- /dev/null +++ b/executors/context_cli.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +CLI subcommands for project context management. + +Usage (via executors.cli): + python -m executors.cli context show + python -m executors.cli context edit --field overview --value "..." + python -m executors.cli context edit --field sprint --value "..." + python -m executors.cli context adr add ADR-001 "Use Redis" "Use Redis for caching" + python -m executors.cli context adr remove ADR-001 + python -m executors.cli context cmd add build "npm run build" + python -m executors.cli context cmd remove build + python -m executors.cli context forbidden add "secrets/" + python -m executors.cli context injection on|off + python -m executors.cli context preview --executor codex-cli --goal "refactor auth" +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +from executors.context import WorkspaceContextManager +from executors.prompt_builder import PromptBuilder +from executors.types import ProjectContext + + +# --------------------------------------------------------------------------- +# Field mapping: CLI field name → context attribute + type +# --------------------------------------------------------------------------- + +_TEXT_FIELDS = { + "overview": ("project_overview", "Project overview"), + "architecture": ("architecture_notes", "Architecture notes"), + "sprint": ("current_sprint", "Current sprint"), + "conventions": ("coding_conventions", "Coding conventions"), +} + + +async def cmd_show(mgr: WorkspaceContextManager, json_output: bool = False) -> None: + """Display the full project context.""" + ctx = mgr.get_context() + + if json_output: + data = { + "project_overview": ctx.project_overview, + "architecture_notes": ctx.architecture_notes, + "current_sprint": ctx.current_sprint, + "adr_summaries": [ + {"id": a.id, "title": a.title, "decision": a.decision} + for a in ctx.adr_summaries + ], + "common_commands": [ + {"label": c.label, "command": c.command} + for c in ctx.common_commands + ], + "test_commands": [ + {"label": c.label, "command": c.command} + for c in ctx.test_commands + ], + "forbidden_areas": ctx.forbidden_areas, + "coding_conventions": ctx.coding_conventions, + "recent_tasks": [ + {"thread_id": t.thread_id, "title": t.title, "executor": t.executor, + "status": t.status, "completed_at": t.completed_at} + for t in ctx.recent_tasks + ], + "context_injection_enabled": ctx.context_injection_enabled, + } + print(json.dumps(data, indent=2, ensure_ascii=False)) + return + + print("=== Project Context ===") + print(f"Injection: {'enabled' if ctx.context_injection_enabled else 'DISABLED'}") + + def _section(title: str, content: str, max_len: int = 200): + if not content: + return + print(f"\n--- {title} ---") + if len(content) > max_len: + print(content[:max_len] + "…") + else: + print(content) + + _section("Overview", ctx.project_overview) + _section("Architecture", ctx.architecture_notes, 500) + _section("Sprint", ctx.current_sprint) + + if ctx.adr_summaries: + print(f"\n--- ADRs ({len(ctx.adr_summaries)}) ---") + for a in ctx.adr_summaries: + print(f" {a.id}: {a.title}") + print(f" → {a.decision}") + + if ctx.common_commands: + print(f"\n--- Common Commands ---") + for c in ctx.common_commands: + print(f" {c.label}: {c.command}") + + if ctx.test_commands: + print(f"\n--- Test Commands ---") + for c in ctx.test_commands: + print(f" {c.label}: {c.command}") + + if ctx.forbidden_areas: + print(f"\n--- Forbidden Areas ---") + for p in ctx.forbidden_areas: + print(f" ✗ {p}") + + _section("Conventions", ctx.coding_conventions) + + if ctx.recent_tasks: + print(f"\n--- Recent Tasks ({len(ctx.recent_tasks)}) ---") + for t in ctx.recent_tasks[-5:]: + icon = "✓" if t.status == "done" else "✗" + print(f" {icon} {t.title} ({t.executor})") + + +async def cmd_edit(mgr: WorkspaceContextManager, field: str, value: str) -> None: + """Edit a text field of the project context.""" + if field not in _TEXT_FIELDS: + valid = ", ".join(_TEXT_FIELDS.keys()) + print(f"Invalid field: {field}. Valid: {valid}", file=sys.stderr) + sys.exit(1) + + attr_name, label = _TEXT_FIELDS[field] + setattr(mgr.get_context(), attr_name, value) + mgr.save() + print(f"Updated {label}") + + +async def cmd_adr(mgr: WorkspaceContextManager, action: str, *args: str) -> None: + """Add or remove an ADR.""" + if action == "add" and len(args) >= 3: + adr_id, title, decision = args[0], args[1], " ".join(args[2:]) + mgr.add_adr(adr_id, title, decision) + print(f"Added ADR: {adr_id}") + elif action == "remove" and len(args) >= 1: + mgr.remove_adr(args[0]) + print(f"Removed ADR: {args[0]}") + else: + print("Usage: context adr add <decision>", file=sys.stderr) + print(" context adr remove <id>", file=sys.stderr) + sys.exit(1) + + +async def cmd_command(mgr: WorkspaceContextManager, action: str, *args: str) -> None: + """Add or remove a common command.""" + if action == "add" and len(args) >= 2: + label, command = args[0], " ".join(args[1:]) + mgr.add_common_command(label, command) + print(f"Added command: {label}") + elif action == "remove" and len(args) >= 1: + mgr.remove_common_command(args[0]) + print(f"Removed command: {args[0]}") + else: + print("Usage: context cmd add <label> <command>", file=sys.stderr) + print(" context cmd remove <label>", file=sys.stderr) + sys.exit(1) + + +async def cmd_forbidden(mgr: WorkspaceContextManager, action: str, *args: str) -> None: + """Add or remove a forbidden area.""" + if action == "add" and len(args) >= 1: + mgr.add_forbidden_area(args[0]) + print(f"Added forbidden area: {args[0]}") + elif action == "remove" and len(args) >= 1: + mgr.remove_forbidden_area(args[0]) + print(f"Removed forbidden area: {args[0]}") + else: + print("Usage: context forbidden add <path>", file=sys.stderr) + print(" context forbidden remove <path>", file=sys.stderr) + sys.exit(1) + + +async def cmd_injection(mgr: WorkspaceContextManager, action: str) -> None: + """Enable or disable context injection.""" + if action in ("on", "enable", "true"): + mgr.set_injection_enabled(True) + print("Context injection: ENABLED") + elif action in ("off", "disable", "false"): + mgr.set_injection_enabled(False) + print("Context injection: DISABLED") + else: + print("Usage: context injection on|off", file=sys.stderr) + sys.exit(1) + + +async def cmd_preview( + mgr: WorkspaceContextManager, + executor_id: str, + goal: str = "", +) -> None: + """Preview the injected context for a specific executor.""" + ctx = mgr.get_context() + builder = PromptBuilder() + + if not ctx.context_injection_enabled: + print("Context injection is DISABLED. No context will be injected.") + print(f"\nPrompt would be:\n {goal or '(no goal)'}") + return + + preview = builder.build_injection_preview(ctx, executor_id) + snapshot = builder.build(user_prompt=goal, context=ctx, executor_id=executor_id) + + print(f"=== Context Injection Preview ===") + print(f"Executor: {executor_id}") + print(f"Est. tokens: {snapshot.estimated_tokens}") + print(f"Hash: {mgr.context_hash()}") + print() + print("--- Context to be injected ---") + print(preview) + if goal: + print(f"\n--- User prompt ---") + print(goal) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def handle_context_command( + project_root: Path, + args, # argparse.Namespace +) -> None: + """Dispatch context subcommands.""" + mgr = WorkspaceContextManager(project_root) + + sub = args.context_subcommand + if sub is None: + print("Usage: python -m executors.cli context <command> [...]") + print("Commands: show, edit, adr, cmd, forbidden, injection, preview") + sys.exit(1) + + try: + if sub == "show": + await cmd_show(mgr, getattr(args, "json", False)) + elif sub == "edit": + await cmd_edit(mgr, args.field, args.value) + elif sub == "adr": + await cmd_adr(mgr, args.adr_action, *getattr(args, "adr_args", [])) + elif sub == "cmd": + await cmd_command(mgr, args.cmd_action, *getattr(args, "cmd_args", [])) + elif sub == "forbidden": + await cmd_forbidden(mgr, args.forbidden_action, *getattr(args, "forbidden_args", [])) + elif sub == "injection": + await cmd_injection(mgr, args.injection_action) + elif sub == "preview": + await cmd_preview(mgr, args.executor_id, getattr(args, "goal", "")) + else: + print(f"Unknown context command: {sub}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/executors/deepseek_tui_adapter.py b/executors/deepseek_tui_adapter.py new file mode 100644 index 000000000000..9e62804ec218 --- /dev/null +++ b/executors/deepseek_tui_adapter.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +DeepSeek TUI adapter — stub implementation. + +DeepSeek TUI is an interactive terminal UI that is NOT suitable for +programmatic driving. It outputs ANSI escape sequences, not structured JSON. + +Limitations (permanent architectural constraints): + - Cannot yield structured ``tool_call`` events (ANSI output only) + - Cannot distinguish ``reasoning`` from ``message`` + - No native diff support + - No review gate + - No health check beyond binary presence + +v0.3 status: **STUB** — registered with ``ui_fidelity="low"``. + ``check_health()`` reports ``UNAVAILABLE`` with a clear explanation. + The adapter does NOT attempt to launch the TUI, because driving an + interactive terminal UI from a subprocess is unreliable and would + produce garbage events. + +Future direction (post-v0.5): + If DeepSeek ships a non-TUI (JSON-line) CLI, this adapter will be + upgraded to match the ClaudeCodeAdapter pattern. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterable, Dict, List, Optional + +from executors.types import ( + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunEventType, + RunStatus, + ExecutorHealthResult, + ExecutorHealthStatus, +) +from executors.health import ( + check_command_exists, + make_unavailable_health, +) + +logger = logging.getLogger(__name__) + +DEFAULT_COMMAND = "deepseek-tui" + +# STUB: set to True to attempt TUI launch (experimental, not recommended) +_ALLOW_TUI_LAUNCH = False + + +@dataclass +class _RunState: + run_id: str + status: RunStatus = RunStatus.PENDING + events: List[RunEvent] = field(default_factory=list) + error: Optional[str] = None + + +class DeepSeekTuiAdapter: + """ + STUB adapter for DeepSeek TUI. + + **Does not launch the TUI.** Attempting to programmatically drive an + interactive terminal UI is unreliable and would corrupt the event stream. + + If you need a low-cost DeepSeek worker, configure ``hermes-local`` with + ``model_ref="deepseek-v4-flash"`` instead. + """ + + def __init__(self, workspace: Optional[Path] = None): + self._workspace = workspace or Path.cwd() + self._runs: Dict[str, _RunState] = {} + + # ------------------------------------------------------------------ + # Health — always UNAVAILABLE in v0.3 + # ------------------------------------------------------------------ + + async def check_health(self) -> ExecutorHealthResult: + """Report UNAVAILABLE with explanation. + + Even if ``deepseek-tui`` binary exists, we mark it unavailable + because the TUI cannot be driven programmatically. + """ + command = _resolve_command(None) + found, path = await check_command_exists(command) + if not found: + return make_unavailable_health( + "deepseek-tui", + f"Command not found: '{command}'. " + f"DeepSeek TUI adapter is a STUB in v0.3 — " + f"use 'hermes-local' with model 'deepseek-v4-flash' instead.", + ) + + return make_unavailable_health( + "deepseek-tui", + f"DeepSeek TUI found at {path}, but this adapter is a STUB. " + f"Interactive TUI cannot be driven programmatically. " + f"Use 'hermes-local' with model 'deepseek-v4-flash' as a drop-in replacement.", + ) + + # ------------------------------------------------------------------ + # start — refuses to launch + # ------------------------------------------------------------------ + + async def start( + self, run: AgentRun, config: ExecutorConfig + ) -> AdapterStartResult: + """Refuse to launch with a clear error.""" + run_id = run.id or str(uuid.uuid4()) + + state = _RunState( + run_id=run_id, + status=RunStatus.FAILED, + error=( + "DeepSeek TUI adapter is a STUB in v0.3. " + "The interactive TUI cannot be driven programmatically. " + "To use DeepSeek, select 'hermes-local' and set model to " + "'deepseek-v4-flash'." + ), + ) + self._runs[run_id] = state + + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + + return AdapterStartResult( + external_run_id=run_id, + base_path=str(self._workspace), + ) + + # ------------------------------------------------------------------ + # stop (no-op for stub) + # ------------------------------------------------------------------ + + async def stop(self, run_id: str) -> None: + state = self._runs.get(run_id) + if state and state.status == RunStatus.PENDING: + state.status = RunStatus.CANCELLED + + # ------------------------------------------------------------------ + # stream_events + # ------------------------------------------------------------------ + + async def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + state = self._runs.get(run_id) + if state is None: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": f"Run {run_id} not found"}, + ) + return + + yield RunEvent( + type=RunEventType.FAILED, + payload={ + "error_summary": ( + "DeepSeek TUI cannot be used. " + "Select a different executor (hermes-local, claude-code, or codex)." + ), + "help": "Use 'hermes-local' with model 'deepseek-v4-flash' for DeepSeek.", + }, + ) + + # ------------------------------------------------------------------ + # get_status + # ------------------------------------------------------------------ + + async def get_status(self, run_id: str) -> RunStatus: + state = self._runs.get(run_id) + if state is None: + return RunStatus.FAILED + return state.status + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _append_event(state: _RunState, event: RunEvent) -> None: + event.seq = len(state.events) + event.timestamp = datetime.datetime.utcnow() + state.events.append(event) + + +def _resolve_command(override: Optional[str]) -> str: + if override: + return override + env_cmd = os.environ.get("HERMES_DEEPSEEK_TUI_PATH") + if env_cmd: + return env_cmd + return DEFAULT_COMMAND diff --git a/executors/health.py b/executors/health.py new file mode 100644 index 000000000000..3511a249f07a --- /dev/null +++ b/executors/health.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Executor health checking utilities. + +Each executor adapter implements ``check_health()`` on its own. +This module provides helpers for: + - Checking command presence via ``shutil.which`` + - Checking project path accessibility + - Bulk health checking across the registry +""" + +from __future__ import annotations + +import asyncio +import logging +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, Dict, Optional, Tuple + +from executors.types import ( + ExecutorId, + ExecutorHealthResult, + ExecutorHealthStatus, + ExecutorManifest, +) + +if TYPE_CHECKING: + from executors.registry import ExecutorRegistry + +logger = logging.getLogger(__name__) + + +async def check_command_exists(command: str) -> Tuple[bool, Optional[str]]: + """Check if a command exists on PATH. Returns (found, path).""" + path = shutil.which(command) + if path: + return True, path + return False, None + + +async def check_version(command: str, version_args: str = "--version") -> Optional[str]: + """Try to get version string from a command. + + Returns the first line of stdout on success, or None on failure. + """ + try: + proc = await asyncio.create_subprocess_exec( + command, version_args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=10.0 + ) + if proc.returncode == 0 and stdout: + return stdout.decode("utf-8", errors="replace").strip().split("\n")[0] + return None + except Exception as e: + logger.debug("Version check failed for %s: %s", command, e) + return None + + +async def check_project_path(project_path: Optional[Path]) -> Tuple[bool, Optional[str]]: + """Check if a project path exists and is accessible.""" + if project_path is None: + return True, None # no path to check = default OK + + path = Path(project_path) + if not path.exists(): + return False, f"Path does not exist: {path}" + if not path.is_dir(): + return False, f"Path is not a directory: {path}" + # Basic accessibility check + try: + path.stat() + return True, None + except PermissionError: + return False, f"Permission denied: {path}" + except OSError as e: + return False, f"OS error: {e}" + + +def make_unknown_health(executor_id: ExecutorId) -> ExecutorHealthResult: + """Create a health result with UNKNOWN status.""" + return ExecutorHealthResult( + executor_id=executor_id, + status=ExecutorHealthStatus.UNKNOWN, + ) + + +def make_available_health( + executor_id: ExecutorId, + version: Optional[str] = None, +) -> ExecutorHealthResult: + """Create a health result with AVAILABLE status.""" + return ExecutorHealthResult( + executor_id=executor_id, + status=ExecutorHealthStatus.AVAILABLE, + version=version, + ) + + +def make_unavailable_health( + executor_id: ExecutorId, + error: str, +) -> ExecutorHealthResult: + """Create a health result with UNAVAILABLE status.""" + return ExecutorHealthResult( + executor_id=executor_id, + status=ExecutorHealthStatus.UNAVAILABLE, + error=error, + ) + + +async def check_executor_health( + executor_id: ExecutorId, + command: str, + version_args: str = "--version", +) -> ExecutorHealthResult: + """Generic health check: verify command exists and get version. + + Args: + executor_id: Executor identifier for the result. + command: The binary name to look up (e.g., "claude-code", "codex"). + version_args: Args to pass for version check. + + Returns: + ExecutorHealthResult with status and optional version/error. + """ + found, cmd_path = await check_command_exists(command) + if not found: + return make_unavailable_health( + executor_id, + f"Command not found: '{command}' (checked PATH)", + ) + + version = await check_version(command, version_args) + if version: + return make_available_health(executor_id, version=version) + + # Command exists but version check failed — still mark as available + # (the version check might fail due to non-standard flags) + return make_available_health( + executor_id, + version=f"[{cmd_path}]", + ) + + +async def check_all_executors_health( + registry: "ExecutorRegistry", +) -> Dict[ExecutorId, ExecutorHealthResult]: + """Run health checks on all registered executors. + + Calls each adapter's ``check_health()`` if implemented, otherwise falls + back to UNKNOWN. + + Returns: + Dict mapping executor_id to health result. + """ + results: Dict[ExecutorId, ExecutorHealthResult] = {} + + async def _check_one(m: ExecutorManifest): + try: + adapter = registry.get(m.id) + if hasattr(adapter, "check_health"): + result = await adapter.check_health() + else: + result = make_unknown_health(m.id) + except Exception as e: + logger.warning("Health check failed for %s: %s", m.id, e) + result = make_unavailable_health(m.id, str(e)) + + registry.set_health(result) + return m.id, result + + manifests = registry.list_executors() + tasks = [_check_one(m) for m in manifests] + gathered = await asyncio.gather(*tasks, return_exceptions=True) + + for item in gathered: + if isinstance(item, Exception): + logger.warning("Health check task raised: %s", item) + else: + eid, result = item + results[eid] = result + + return results diff --git a/executors/hermes_local_adapter.py b/executors/hermes_local_adapter.py new file mode 100644 index 000000000000..8f47a92735f3 --- /dev/null +++ b/executors/hermes_local_adapter.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +Hermes Local adapter — runs agents in-process via the existing AIAgent. + +This is the "built-in" executor. It does not spawn a subprocess; instead it +imports ``run_agent.AIAgent`` and calls ``run_conversation()`` directly. + +The adapter wraps the blocking agent call in an asyncio executor thread and +normalizes the message stream into ``RunEvent`` items. + +Status: **full** — start, stop, stream_events, get_status, health. +""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import logging +import os +import sys +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterable, Dict, List, Optional + +from executors.types import ( + ExecutorId, + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunEventType, + RunStatus, + ExecutorHealthResult, + ExecutorHealthStatus, + AgentExecutorAdapter, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# In-memory run state (simplest form; production would want persistence) +# --------------------------------------------------------------------------- + +@dataclass +class _RunState: + run_id: str + status: RunStatus = RunStatus.PENDING + events: List[RunEvent] = field(default_factory=list) + error: Optional[str] = None + cancel_event: Optional[asyncio.Event] = None + task: Optional[asyncio.Task] = None + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class HermesLocalAdapter: + """Runs agents in-process via ``run_agent.AIAgent``.""" + + def __init__(self, workspace: Optional[Path] = None): + self._workspace = workspace or Path.cwd() + self._runs: Dict[str, _RunState] = {} + + # ------------------------------------------------------------------ + # Health + # ------------------------------------------------------------------ + + async def check_health(self) -> ExecutorHealthResult: + """Hermes Local is always available (it's the same process).""" + try: + import run_agent + return ExecutorHealthResult( + executor_id="hermes-local", + status=ExecutorHealthStatus.AVAILABLE, + version="built-in", + ) + except ImportError as e: + return ExecutorHealthResult( + executor_id="hermes-local", + status=ExecutorHealthStatus.UNAVAILABLE, + error=f"run_agent import failed: {e}", + ) + + # ------------------------------------------------------------------ + # start + # ------------------------------------------------------------------ + + async def start( + self, run: AgentRun, config: ExecutorConfig + ) -> AdapterStartResult: + """Create a background task that runs the agent to completion. + + Returns immediately with an AdapterStartResult so the caller can + begin streaming events. + """ + run_id = run.id or str(uuid.uuid4()) + cancel_event = asyncio.Event() + + state = _RunState( + run_id=run_id, + status=RunStatus.RUNNING, + cancel_event=cancel_event, + ) + self._runs[run_id] = state + + # Launch agent in thread (AIAgent is blocking/sync) + task = asyncio.create_task( + self._execute(run, config, state), + name=f"hermes-local-run-{run_id[:8]}", + ) + state.task = task + + # Get git snapshot for diff support + git_snapshot = await self._get_git_snapshot(run.workspace) + + return AdapterStartResult( + external_run_id=run_id, + base_path=str(run.workspace), + git_snapshot=git_snapshot, + ) + + async def _execute( + self, run: AgentRun, config: ExecutorConfig, state: _RunState, + ) -> None: + """Run the agent in a thread, collecting events.""" + try: + # Import here to avoid circular dependency at module level + from run_agent import AIAgent + + workspace = str(run.workspace) if run.workspace else str(self._workspace) + + # Switch to workspace + original_cwd = os.getcwd() + os.chdir(workspace) + + try: + agent = AIAgent( + model=run.model_ref or "deepseek-v4-pro", + # Propagate config extras that AIAgent understands + max_iterations=config.extra.get("max_iterations", 90), + ) + + # Run in thread to avoid blocking event loop + result = await asyncio.get_event_loop().run_in_executor( + None, + lambda: agent.run_conversation( + run.prompt, + task_id=run.id, + ), + ) + + # Normalize messages to events + messages = result.get("messages", []) + self._normalize_messages(state, messages) + + if result.get("completed", True): + self._append_event(state, RunEvent( + type=RunEventType.COMPLETED, + payload={"summary": result.get("summary", "")}, + )) + state.status = RunStatus.COMPLETED + else: + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": "Agent did not complete"}, + )) + state.status = RunStatus.FAILED + + finally: + os.chdir(original_cwd) + + except asyncio.CancelledError: + state.status = RunStatus.CANCELLED + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": "Run cancelled by user"}, + )) + except Exception as e: + logger.exception("Hermes Local run %s failed", run.id) + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": str(e)}, + )) + + def _normalize_messages(self, state: _RunState, messages: List[Dict]) -> None: + """Convert AIAgent message list to RunEvent stream.""" + for msg in messages: + role = msg.get("role", "") + content = msg.get("content", "") + + if role == "assistant": + # Reasoning content + reasoning = msg.get("reasoning", "") + if reasoning: + self._append_event(state, RunEvent( + type=RunEventType.REASONING, + payload={"content": reasoning}, + )) + + # Tool calls + tool_calls = msg.get("tool_calls") + if tool_calls: + for tc in tool_calls: + func = tc.get("function", {}) + try: + args = json.loads(func.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {"raw": func.get("arguments", "")} + self._append_event(state, RunEvent( + type=RunEventType.TOOL_CALL, + payload={ + "tool_name": func.get("name", "unknown"), + "arguments": args, + }, + )) + + # Text content (skip if it's just tool call markers) + if content and not tool_calls: + self._append_event(state, RunEvent( + type=RunEventType.MESSAGE, + payload={"content": content}, + )) + + elif role == "tool": + tool_call_id = msg.get("tool_call_id", "") + self._append_event(state, RunEvent( + type=RunEventType.TOOL_RESULT, + payload={ + "tool_call_id": tool_call_id, + "content": str(content)[:2000], # truncate long results + }, + )) + + # ------------------------------------------------------------------ + # stop + # ------------------------------------------------------------------ + + async def stop(self, run_id: str) -> None: + """Cancel the background task. Idempotent.""" + state = self._runs.get(run_id) + if state is None: + return # already gone + + if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED): + return # already finished + + if state.cancel_event: + state.cancel_event.set() + + if state.task and not state.task.done(): + state.task.cancel() + try: + await state.task + except asyncio.CancelledError: + pass + + state.status = RunStatus.CANCELLED + + # ------------------------------------------------------------------ + # stream_events + # ------------------------------------------------------------------ + + async def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + """Yield events from the run as they arrive. + + Events are buffered in memory; the iterator polls for new events + and yields them as they are appended by the background task. + """ + state = self._runs.get(run_id) + if state is None: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": f"Run {run_id} not found"}, + ) + return + + yielded = 0 + done_statuses = {RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED} + + while True: + # Yield any new events + while yielded < len(state.events): + yield state.events[yielded] + yielded += 1 + + # Check if done + if state.status in done_statuses: + # Ensure FAILED event contract: if run failed but no FAILED + # event was emitted, synthesize one + if state.status == RunStatus.FAILED: + has_failed_event = any( + e.type == RunEventType.FAILED + for e in state.events[yielded:] + ) + # Check already-yielded events too + has_failed_event = has_failed_event or any( + e.type == RunEventType.FAILED + for e in state.events[:yielded] + ) + if not has_failed_event: + yield RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error or "Unknown error"}, + ) + return + + await asyncio.sleep(0.1) + + # ------------------------------------------------------------------ + # get_status + # ------------------------------------------------------------------ + + async def get_status(self, run_id: str) -> RunStatus: + """Return the current status of a run.""" + state = self._runs.get(run_id) + if state is None: + return RunStatus.FAILED # run doesn't exist — treat as terminal + return state.status + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + @staticmethod + def _append_event(state: _RunState, event: RunEvent) -> None: + """Append an event to the state buffer.""" + event.seq = len(state.events) + event.timestamp = datetime.datetime.utcnow() + state.events.append(event) + + @staticmethod + async def _get_git_snapshot(workspace: Path) -> Optional[str]: + """Get the current git HEAD sha for diff tracking.""" + try: + proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + ) + stdout, _ = await asyncio.wait_for( + proc.communicate(), timeout=5.0 + ) + if proc.returncode == 0: + return stdout.decode().strip() + except Exception: + pass + return None diff --git a/executors/inbox.py b/executors/inbox.py new file mode 100644 index 000000000000..b5a34d0e403b --- /dev/null +++ b/executors/inbox.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +InboxManager — external task inbox with JSON persistence. + +Stores items at ``<project_root>/.hermes/inbox.json``. +Supports: manual, cli, feishu (stub), discord (stub), scheduler (stub). + +Writeback status for each source: + manual — N/A (no external source to write back to) + cli — available (writes to ~/.hermes/inbox-results/<id>.json) + feishu — unavailable (stub, API not connected) + discord — unavailable (stub, API not connected) + scheduler — unavailable (stub, no cron runner) +""" + +from __future__ import annotations + +import datetime +import json +import logging +import uuid +from dataclasses import asdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +from executors.types import ( + InboxItem, + InboxSource, + InboxStatus, + InboxResultCallback, + TaskDraft, +) + +logger = logging.getLogger(__name__) + +INBOX_FILENAME = "inbox.json" + +# Source writeback availability +_WRITEBACK_AVAILABLE: Dict[InboxSource, bool] = { + InboxSource.DESKTOP: False, + InboxSource.CLI: True, + InboxSource.FEISHU: False, + InboxSource.DISCORD: False, + InboxSource.SCHEDULER: False, +} + + +class InboxManager: + """Manages inbox items at ``<project_root>/.hermes/inbox.json``.""" + + def __init__(self, project_root: Path): + self._project_root = Path(project_root).resolve() + self._inbox_dir = self._project_root / ".hermes" + self._inbox_path = self._inbox_dir / INBOX_FILENAME + self._items: Optional[List[InboxItem]] = None + + # ------------------------------------------------------------------ + # Load / Save + # ------------------------------------------------------------------ + + def _load(self) -> List[InboxItem]: + if self._items is not None: + return self._items + if self._inbox_path.exists(): + try: + raw = json.loads(self._inbox_path.read_text()) + self._items = [self._from_dict(it) for it in raw] + return self._items + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning("Corrupt inbox.json: %s", e) + self._items = [] + return self._items + + def _save(self) -> None: + self._inbox_dir.mkdir(parents=True, exist_ok=True) + data = [self._to_dict(it) for it in (self._items or [])] + self._inbox_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False, default=str) + ) + + # ------------------------------------------------------------------ + # Create + # ------------------------------------------------------------------ + + def add( + self, + source: InboxSource, + title: str, + body: str, + raw_payload: Optional[Dict[str, Any]] = None, + suggested_executor: Optional[str] = None, + project_hint: Optional[str] = None, + priority: str = "normal", + expires_at: Optional[datetime.datetime] = None, + ) -> InboxItem: + """Add a new inbox item.""" + items = self._load() + item_id = f"inbox-{uuid.uuid4().hex[:12]}" + + draft = TaskDraft( + title=title, + suggested_prompt=body, + suggested_executor=suggested_executor, + project_hint=project_hint, + priority=priority, + ) + + item = InboxItem( + id=item_id, + source=source, + raw_payload=raw_payload or {"title": title, "body": body}, + draft=draft, + status=InboxStatus.PENDING, + created_at=datetime.datetime.now(datetime.timezone.utc), + expires_at=expires_at, + ) + + items.append(item) + self._items = items + self._save() + logger.info("Inbox item added: %s [%s]", item_id, source.value) + return item + + # ------------------------------------------------------------------ + # Query + # ------------------------------------------------------------------ + + def list_items( + self, + status: Optional[InboxStatus] = None, + source: Optional[InboxSource] = None, + ) -> List[InboxItem]: + """List inbox items, optionally filtered.""" + items = self._load() + if status: + items = [it for it in items if it.status == status] + if source: + items = [it for it in items if it.source == source] + return items + + def list_pending(self) -> List[InboxItem]: + return self.list_items(status=InboxStatus.PENDING) + + def get(self, item_id: str) -> Optional[InboxItem]: + for it in self._load(): + if it.id == item_id: + return it + return None + + def count_by_status(self) -> Dict[str, int]: + counts: Dict[str, int] = {} + for it in self._load(): + counts[it.status.value] = counts.get(it.status.value, 0) + 1 + return counts + + def count_pending_by_source(self) -> Dict[str, int]: + counts: Dict[str, int] = {} + for it in self.list_pending(): + counts[it.source.value] = counts.get(it.source.value, 0) + 1 + return counts + + # ------------------------------------------------------------------ + # State transitions + # ------------------------------------------------------------------ + + def convert_to_task(self, item_id: str, task_id: str) -> Optional[InboxItem]: + """Mark item as confirmed and link to a task thread.""" + item = self.get(item_id) + if item is None: + return None + if item.status != InboxStatus.PENDING: + logger.warning("Item %s not pending: %s", item_id, item.status.value) + return item + + item.status = InboxStatus.CONFIRMED + item.linked_task_id = task_id + self._save() + logger.info("Inbox item %s confirmed → task %s", item_id, task_id) + return item + + def reject(self, item_id: str, reason: str = "") -> Optional[InboxItem]: + """Reject an inbox item.""" + item = self.get(item_id) + if item is None: + return None + item.status = InboxStatus.REJECTED + item.rejected_reason = reason + self._save() + return item + + def archive(self, item_id: str) -> Optional[InboxItem]: + """Archive an inbox item.""" + item = self.get(item_id) + if item is None: + return None + item.status = InboxStatus.ARCHIVED + self._save() + return item + + def expire(self, item_id: str) -> Optional[InboxItem]: + """Mark item as expired.""" + item = self.get(item_id) + if item is None: + return None + item.status = InboxStatus.EXPIRED + self._save() + return item + + def update_draft( + self, item_id: str, + title: Optional[str] = None, + prompt: Optional[str] = None, + executor: Optional[str] = None, + project: Optional[str] = None, + priority: Optional[str] = None, + ) -> Optional[InboxItem]: + """Edit the task draft before converting.""" + item = self.get(item_id) + if item is None: + return None + if title is not None: + item.draft.title = title + item.draft.user_edited = True + if prompt is not None: + item.draft.suggested_prompt = prompt + item.draft.user_edited = True + if executor is not None: + item.draft.suggested_executor = executor + if project is not None: + item.draft.project_hint = project + if priority is not None: + item.draft.priority = priority + self._save() + return item + + # ------------------------------------------------------------------ + # Writeback + # ------------------------------------------------------------------ + + def get_writeback_callback( + self, item_id: str, run_id: str, summary: str, + status: str = "done", changed_files_count: int = 0, + ) -> Optional[InboxResultCallback]: + """Create a result callback for writeback. May be unavailable.""" + item = self.get(item_id) + if item is None: + return None + + available = _WRITEBACK_AVAILABLE.get(item.source, False) + return InboxResultCallback( + inbox_item_id=item_id, + run_id=run_id, + status=status, + summary=summary[:500], + changed_files_count=changed_files_count, + writeback_available=available, + ) + + @staticmethod + def writeback_destination(item: InboxItem) -> str: + """Describe where the result would be written back.""" + if item.source == InboxSource.DESKTOP: + return "N/A (manual entry)" + if item.source == InboxSource.CLI: + return f"~/.hermes/inbox-results/{item.id}.json" + if item.source == InboxSource.FEISHU: + return "Feishu thread (unavailable — stub)" + if item.source == InboxSource.DISCORD: + return "Discord channel (unavailable — stub)" + if item.source == InboxSource.SCHEDULER: + return "Scheduler job status (unavailable — stub)" + return "Unknown" + + # ------------------------------------------------------------------ + # Serialization + # ------------------------------------------------------------------ + + @staticmethod + def _to_dict(item: InboxItem) -> Dict[str, Any]: + return { + "id": item.id, + "source": item.source.value, + "raw_payload": item.raw_payload, + "draft": asdict(item.draft), + "status": item.status.value, + "created_at": item.created_at.isoformat(), + "expires_at": item.expires_at.isoformat() if item.expires_at else None, + "linked_task_id": item.linked_task_id, + "rejected_reason": item.rejected_reason, + } + + @staticmethod + def _from_dict(d: Dict[str, Any]) -> InboxItem: + return InboxItem( + id=d["id"], + source=InboxSource(d["source"]), + raw_payload=d.get("raw_payload", {}), + draft=TaskDraft(**d.get("draft", {})), + status=InboxStatus(d.get("status", "pending")), + created_at=datetime.datetime.fromisoformat(d["created_at"]) if d.get("created_at") else datetime.datetime.now(datetime.timezone.utc), + expires_at=datetime.datetime.fromisoformat(d["expires_at"]) if d.get("expires_at") else None, + linked_task_id=d.get("linked_task_id"), + rejected_reason=d.get("rejected_reason"), + ) + + +def create_inbox_manager(project_root: Path) -> InboxManager: + return InboxManager(project_root) diff --git a/executors/inbox_cli.py b/executors/inbox_cli.py new file mode 100644 index 000000000000..abf8dd829a27 --- /dev/null +++ b/executors/inbox_cli.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +CLI subcommands for inbox management. + +Usage (via executors.cli): + python -m executors.cli inbox add --source cli --title "Fix bug" --body "Fix login error" + python -m executors.cli inbox add --source feishu --title "Review PR" --body "..." + python -m executors.cli inbox list [--status pending|confirmed|rejected|archived] [--source cli|feishu|...] + python -m executors.cli inbox show <item_id> + python -m executors.cli inbox convert <item_id> --task-id <task_id> + python -m executors.cli inbox reject <item_id> [--reason "not relevant"] + python -m executors.cli inbox archive <item_id> + python -m executors.cli inbox edit <item_id> --title "New title" --prompt "New prompt" + python -m executors.cli inbox summary +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +from executors.inbox import InboxManager +from executors.types import InboxSource, InboxStatus + + +SOURCE_ICONS = { + "desktop": "✎", + "cli": "⌨", + "feishu": "飞", + "discord": "◌", + "scheduler": "⏱", +} + +STATUS_ICONS = { + "pending": "●", + "confirmed": "✓", + "rejected": "✗", + "archived": "○", + "expired": "⊗", +} + + +async def cmd_add( + mgr: InboxManager, + source: str, + title: str, + body: str, + executor: str = "", + project: str = "", + priority: str = "normal", +) -> None: + """Add a new inbox item.""" + try: + src = InboxSource(source) + except ValueError: + valid = ", ".join(s.value for s in InboxSource) + print(f"Invalid source: {source}. Valid: {valid}", file=sys.stderr) + sys.exit(1) + + raw = {"title": title, "body": body} + if project: + raw["project_hint"] = project + + item = mgr.add( + source=src, + title=title, + body=body, + raw_payload=raw, + suggested_executor=executor or None, + project_hint=project or None, + priority=priority, + ) + icon = SOURCE_ICONS.get(item.source.value, "?") + print(f"{icon} Added inbox item: {item.id}") + print(f" Title: {title}") + print(f" Source: {item.source.value}") + print(f" Status: {item.status.value}") + + +async def cmd_list( + mgr: InboxManager, + status: str = "", + source: str = "", + json_output: bool = False, +) -> None: + """List inbox items with optional filters.""" + status_filter = InboxStatus(status) if status else None + source_filter = InboxSource(source) if source else None + items = mgr.list_items(status=status_filter, source=source_filter) + + if json_output: + data = [] + for it in items: + data.append({ + "id": it.id, + "source": it.source.value, + "title": it.draft.title, + "status": it.status.value, + "created_at": it.created_at.isoformat(), + "linked_task_id": it.linked_task_id, + }) + print(json.dumps(data, indent=2, ensure_ascii=False)) + return + + if not items: + print("No inbox items found.") + return + + print(f"{'ID':<18} {'S':<3} {'Title':<40} {'Status':<12} {'Created'}") + print("-" * 100) + for it in items: + sid = it.id[-14:] + src_icon = SOURCE_ICONS.get(it.source.value, "?") + st_icon = STATUS_ICONS.get(it.status.value, "?") + title = it.draft.title[:38] + created = it.created_at.strftime("%m-%d %H:%M") + linked = f" → {it.linked_task_id[:12]}" if it.linked_task_id else "" + print(f"{sid:<18} {src_icon:<3} {title:<40} {st_icon} {it.status.value:<9} {created}{linked}") + + +async def cmd_show(mgr: InboxManager, item_id: str, json_output: bool = False) -> None: + """Show full details of an inbox item.""" + item = mgr.get(item_id) + if item is None: + print(f"Item not found: {item_id}", file=sys.stderr) + sys.exit(1) + + if json_output: + data = { + "id": item.id, + "source": item.source.value, + "status": item.status.value, + "title": item.draft.title, + "prompt": item.draft.suggested_prompt, + "executor": item.draft.suggested_executor, + "project": item.draft.project_hint, + "priority": item.draft.priority, + "user_edited": item.draft.user_edited, + "created_at": item.created_at.isoformat(), + "expires_at": item.expires_at.isoformat() if item.expires_at else None, + "linked_task_id": item.linked_task_id, + "rejected_reason": item.rejected_reason, + "raw_payload": item.raw_payload, + "writeback_dest": InboxManager.writeback_destination(item), + } + print(json.dumps(data, indent=2, ensure_ascii=False, default=str)) + return + + icon = SOURCE_ICONS.get(item.source.value, "?") + print(f"{icon} Inbox Item: {item.id}") + print(f" Source: {item.source.value}") + print(f" Status: {STATUS_ICONS.get(item.status.value, '?')} {item.status.value}") + print(f" Title: {item.draft.title}") + print(f" Prompt: {item.draft.suggested_prompt[:200]}") + if item.draft.suggested_executor: + print(f" Executor: {item.draft.suggested_executor}") + if item.draft.project_hint: + print(f" Project: {item.draft.project_hint}") + print(f" Priority: {item.draft.priority}") + print(f" Edited: {'yes' if item.draft.user_edited else 'no'}") + print(f" Created: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}") + if item.expires_at: + print(f" Expires: {item.expires_at.strftime('%Y-%m-%d %H:%M:%S')}") + if item.linked_task_id: + print(f" Task: {item.linked_task_id}") + if item.rejected_reason: + print(f" Rejected: {item.rejected_reason}") + print(f" Writeback: {InboxManager.writeback_destination(item)}") + + +async def cmd_convert(mgr: InboxManager, item_id: str, task_id: str) -> None: + """Convert an inbox item to a task thread.""" + item = mgr.convert_to_task(item_id, task_id) + if item is None: + print(f"Item not found: {item_id}", file=sys.stderr) + sys.exit(1) + print(f"✓ Converted {item_id} → task {task_id}") + print(f" Title: {item.draft.title}") + print(f" Status: {item.status.value}") + + +async def cmd_reject(mgr: InboxManager, item_id: str, reason: str = "") -> None: + """Reject an inbox item.""" + item = mgr.reject(item_id, reason) + if item is None: + print(f"Item not found: {item_id}", file=sys.stderr) + sys.exit(1) + print(f"✗ Rejected: {item_id}") + if reason: + print(f" Reason: {reason}") + + +async def cmd_archive(mgr: InboxManager, item_id: str) -> None: + """Archive an inbox item.""" + item = mgr.archive(item_id) + if item is None: + print(f"Item not found: {item_id}", file=sys.stderr) + sys.exit(1) + print(f"○ Archived: {item_id}") + + +async def cmd_edit( + mgr: InboxManager, + item_id: str, + title: str = "", + prompt: str = "", + executor: str = "", + project: str = "", + priority: str = "", +) -> None: + """Edit the task draft of a pending inbox item.""" + item = mgr.update_draft( + item_id, + title=title or None, + prompt=prompt or None, + executor=executor or None, + project=project or None, + priority=priority or None, + ) + if item is None: + print(f"Item not found: {item_id}", file=sys.stderr) + sys.exit(1) + print(f"✓ Updated draft for {item_id}") + if title: + print(f" Title: {title}") + + +async def cmd_summary(mgr: InboxManager) -> None: + """Show inbox summary (counts by status and source).""" + counts = mgr.count_by_status() + src_counts = mgr.count_pending_by_source() + + print("=== Inbox Summary ===") + print(f"Pending: {counts.get('pending', 0)}") + print(f"Confirmed: {counts.get('confirmed', 0)}") + print(f"Rejected: {counts.get('rejected', 0)}") + print(f"Archived: {counts.get('archived', 0)}") + print(f"Expired: {counts.get('expired', 0)}") + if src_counts: + print(f"\nPending by source:") + for src, n in sorted(src_counts.items()): + icon = SOURCE_ICONS.get(src, "?") + print(f" {icon} {src:<12} {n}") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def handle_inbox_command( + project_root: Path, + args, +) -> None: + """Dispatch inbox subcommands.""" + mgr = InboxManager(project_root) + + sub = args.inbox_subcommand + if sub is None: + print("Usage: inbox {add|list|show|convert|reject|archive|edit|summary} [...]", file=sys.stderr) + sys.exit(1) + + try: + if sub == "add": + await cmd_add( + mgr, + source=args.source, + title=args.title, + body=args.body, + executor=getattr(args, "executor", ""), + project=getattr(args, "project", ""), + priority=getattr(args, "priority", "normal"), + ) + elif sub == "list": + await cmd_list( + mgr, + status=getattr(args, "status", ""), + source=getattr(args, "source", ""), + json_output=getattr(args, "json", False), + ) + elif sub == "show": + await cmd_show(mgr, args.item_id, getattr(args, "json", False)) + elif sub == "convert": + await cmd_convert(mgr, args.item_id, args.task_id) + elif sub == "reject": + await cmd_reject(mgr, args.item_id, getattr(args, "reason", "")) + elif sub == "archive": + await cmd_archive(mgr, args.item_id) + elif sub == "edit": + await cmd_edit( + mgr, args.item_id, + title=getattr(args, "title", ""), + prompt=getattr(args, "prompt", ""), + executor=getattr(args, "executor", ""), + project=getattr(args, "project", ""), + priority=getattr(args, "priority", ""), + ) + elif sub == "summary": + await cmd_summary(mgr) + else: + print(f"Unknown inbox command: {sub}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/executors/ipc.py b/executors/ipc.py new file mode 100644 index 000000000000..bae6c719a346 --- /dev/null +++ b/executors/ipc.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +IPC protocol definitions for Hermes Desktop Electron bridge. + +Defines the TypeScript-compatible interface that the Electron main process +exposes to the renderer via preload. These are the data models used by +window.hermesAPI.* calls. + +The actual IPC wire format is JSON over stdin/stdout (for CLI testing) +or Electron contextBridge (for production). Both use the same schema. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +# --------------------------------------------------------------------------- +# Run lifecycle +# --------------------------------------------------------------------------- + + +@dataclass +class CreateRunRequest: + thread_id: str + prompt: str + executor_type: str + run_type: str = "main" # "main" | "review" | "qa" + project_root: str = "." + + +@dataclass +class CreateRunResponse: + run_id: str + status: str # "created" + + +@dataclass +class StopRunRequest: + run_id: str + + +@dataclass +class ContinueRunRequest: + thread_id: str + prompt: str + previous_run_id: str + executor_type: str = "" + + +@dataclass +class ContinueRunResponse: + run_id: str + + +@dataclass +class RetryRunRequest: + thread_id: str + prompt: str + executor_type: str = "" + + +@dataclass +class RetryRunResponse: + run_id: str + run_seq: int + + +# --------------------------------------------------------------------------- +# Review / QA +# --------------------------------------------------------------------------- + + +@dataclass +class TriggerReviewRequest: + main_run_id: str + worktree_path: str + diff_patch: str = "" + task_goal: str = "" + changed_files: List[str] = field(default_factory=list) + # executor_type defaults to the review agent's recommendation + executor_type: str = "" + + +@dataclass +class TriggerReviewResponse: + review_run_id: str + status: str + + +@dataclass +class TriggerQARequest: + main_run_id: str + worktree_path: str + test_commands: List[str] = field(default_factory=list) + task_goal: str = "" + # executor_type defaults to the QA agent's recommendation + executor_type: str = "" + + +@dataclass +class TriggerQAResponse: + qa_run_id: str + status: str + + +# --------------------------------------------------------------------------- +# Data reads +# --------------------------------------------------------------------------- + + +@dataclass +class GetChangedFilesRequest: + run_id: str + + +@dataclass +class GetChangedFilesResponse: + run_id: str + files: List[Dict[str, Any]] = field(default_factory=list) + # Each file: {path, status, additions, deletions, absolute_path} + + +@dataclass +class GetGatewayStatusResponse: + connected: bool + model: str = "" + error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Event streaming +# --------------------------------------------------------------------------- + + +@dataclass +class RawRunEvent: + """IPC-level event as received by the renderer.""" + event: str # "tool.completed", "diff", "message.delta", ... + run_id: str + timestamp: float # unix epoch + payload: Dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Approval +# --------------------------------------------------------------------------- + + +@dataclass +class ResolveApprovalRequest: + run_id: str + decision: str # "continue" | "accept" | "done" | "reject" + comment: Optional[str] = None + + +# --------------------------------------------------------------------------- +# HermesAPI — the full preload interface (for documentation) +# --------------------------------------------------------------------------- + +class HermesAPI: + """Typed interface for window.hermesAPI in Electron. + + This class is NOT instantiated in Python. It exists as documentation + of the IPC contract between backend (Python) and frontend (TS/Electron). + + All methods return Promises. streamRunEvents returns an unsubscribe function. + """ + + # Run lifecycle + async def createRun(self, thread_id: str, prompt: str, executor_type: str) -> dict: ... + async def stopRun(self, run_id: str) -> None: ... + async def continueRun(self, thread_id: str, prompt: str, previous_run_id: str) -> dict: ... + async def retryRun(self, thread_id: str, prompt: str) -> dict: ... + + # Event streaming + def streamRunEvents(self, run_id: str, callback) -> callable: ... # returns unsubscribe + + # Approval + async def resolveApproval(self, run_id: str, decision: str, comment: str = None) -> None: ... + + # Review / QA + async def triggerReview(self, main_run_id: str) -> dict: ... + async def triggerQA(self, main_run_id: str) -> dict: ... + + # Data reads + async def getTaskThreads(self, project_id: str) -> list: ... + async def getChangedFiles(self, run_id: str) -> dict: ... + async def getGatewayStatus(self) -> dict: ... + + +# --------------------------------------------------------------------------- +# IPC channel names (for Electron ipcMain/ipcRenderer) +# --------------------------------------------------------------------------- + +IPC_CHANNELS = { + "run:create": "CreateRunRequest → CreateRunResponse", + "run:stop": "StopRunRequest → void", + "run:continue": "ContinueRunRequest → ContinueRunResponse", + "run:retry": "RetryRunRequest → RetryRunResponse", + "run:events:subscribe": "run_id → stream of RawRunEvent", + "run:events:unsubscribe": "run_id → void", + "review:trigger": "TriggerReviewRequest → TriggerReviewResponse", + "qa:trigger": "TriggerQARequest → TriggerQAResponse", + "approval:resolve": "ResolveApprovalRequest → void", + "data:changed-files": "GetChangedFilesRequest → GetChangedFilesResponse", + "data:gateway-status": "void → GetGatewayStatusResponse", +} diff --git a/executors/opencode_adapter.py b/executors/opencode_adapter.py new file mode 100644 index 000000000000..7a6284135428 --- /dev/null +++ b/executors/opencode_adapter.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +OpenCode adapter — minimal subprocess wrapper. + +OpenCode is a local open-source coding agent CLI. This adapter wraps +``opencode`` as a subprocess, following the same pattern as the Codex +and Claude Code adapters. + +Minimal v0.5 implementation: + - Command launch via asyncio subprocess + - stdout/stderr log collection + - Exit status reporting + - Health check via ``which opencode && opencode --version`` + +If ``opencode`` is not available: health reports UNAVAILABLE. +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, AsyncIterable, Dict, List, Optional + +from executors.types import ( + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunEventType, + RunStatus, + ExecutorHealthResult, + ExecutorHealthStatus, +) +from executors.health import ( + check_command_exists, + check_version, + make_unavailable_health, + make_available_health, +) + +logger = logging.getLogger(__name__) + +DEFAULT_COMMAND = "opencode" +DEFAULT_VERSION_ARGS = "--version" + + +@dataclass +class _RunState: + run_id: str + process: Optional[asyncio.subprocess.Process] = None + status: RunStatus = RunStatus.PENDING + events: List[RunEvent] = field(default_factory=list) + error: Optional[str] = None + stdout_lines: List[str] = field(default_factory=list) + stderr_lines: List[str] = field(default_factory=list) + + +class OpenCodeAdapter: + """Runs OpenCode CLI as a subprocess.""" + + def __init__(self, workspace: Optional[Path] = None): + self._workspace = workspace or Path.cwd() + self._runs: Dict[str, _RunState] = {} + + async def check_health(self) -> ExecutorHealthResult: + command = _resolve_command(None) + found, path = await check_command_exists(command) + if not found: + return make_unavailable_health( + "opencode", + f"Command not found: '{command}' — " + f"install via: npm install -g opencode-ai " + f"or set HERMES_OPENCODE_PATH", + ) + version = await check_version(command, DEFAULT_VERSION_ARGS) + return make_available_health("opencode", version=version) + + async def start( + self, run: AgentRun, config: ExecutorConfig + ) -> AdapterStartResult: + run_id = run.id or str(uuid.uuid4()) + command = _resolve_command(config.path) + workspace = str(run.workspace) if run.workspace else str(self._workspace) + + state = _RunState(run_id=run_id, status=RunStatus.RUNNING) + self._runs[run_id] = state + + cmd = [command, "-p", run.prompt] + + try: + state.process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workspace, + env={**os.environ, **(run.env or {})}, + ) + if state.process.stdin: + state.process.stdin.close() + + asyncio.create_task( + self._collect_output(state), + name=f"opencode-collect-{run_id[:8]}", + ) + except FileNotFoundError: + state.status = RunStatus.FAILED + state.error = f"Command not found: {command}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": state.error}, + )) + + return AdapterStartResult( + external_run_id=run_id, + base_path=workspace, + ) + + async def _collect_output(self, state: _RunState) -> None: + if state.process is None: + return + try: + async def read_stream(stream, lines): + while True: + line = await stream.readline() + if not line: + break + decoded = line.decode("utf-8", errors="replace").rstrip("\n") + lines.append(decoded) + self._append_event(state, RunEvent( + type=RunEventType.LOG, + payload={"line": decoded}, + )) + await asyncio.gather( + read_stream(state.process.stdout, state.stdout_lines), + read_stream(state.process.stderr, state.stderr_lines), + ) + await state.process.wait() + exit_code = state.process.returncode + if exit_code == 0: + state.status = RunStatus.COMPLETED + self._append_event(state, RunEvent( + type=RunEventType.COMPLETED, + payload={"exit_code": exit_code}, + )) + else: + stderr_summary = "\n".join(state.stderr_lines[-20:]) + state.status = RunStatus.FAILED + state.error = f"Exit code {exit_code}: {stderr_summary}" + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"exit_code": exit_code, "error_summary": stderr_summary[:500]}, + )) + except Exception as e: + state.status = RunStatus.FAILED + state.error = str(e) + self._append_event(state, RunEvent( + type=RunEventType.FAILED, + payload={"error_summary": str(e)}, + )) + + async def stop(self, run_id: str) -> None: + state = self._runs.get(run_id) + if state is None or state.process is None: + return + if state.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED): + return + try: + state.process.terminate() + try: + await asyncio.wait_for(state.process.wait(), timeout=10.0) + except asyncio.TimeoutError: + state.process.kill() + await state.process.wait() + except ProcessLookupError: + pass + state.status = RunStatus.CANCELLED + + async def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + state = self._runs.get(run_id) + if state is None: + yield RunEvent(type=RunEventType.FAILED, payload={"error_summary": f"Run {run_id} not found"}) + return + yielded = 0 + done_statuses = {RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED} + while True: + while yielded < len(state.events): + yield state.events[yielded] + yielded += 1 + if state.status in done_statuses: + if state.status == RunStatus.FAILED: + has_failed = any(e.type == RunEventType.FAILED for e in state.events) + if not has_failed: + yield RunEvent(type=RunEventType.FAILED, payload={"error_summary": state.error or "Unknown error"}) + return + await asyncio.sleep(0.1) + + async def get_status(self, run_id: str) -> RunStatus: + state = self._runs.get(run_id) + return state.status if state else RunStatus.FAILED + + @staticmethod + def _append_event(state: _RunState, event: RunEvent) -> None: + event.seq = len(state.events) + event.timestamp = datetime.datetime.utcnow() + state.events.append(event) + + +def _resolve_command(override: Optional[str]) -> str: + if override: + return override + env_cmd = os.environ.get("HERMES_OPENCODE_PATH") + if env_cmd: + return env_cmd + return DEFAULT_COMMAND diff --git a/executors/prompt_builder.py b/executors/prompt_builder.py new file mode 100644 index 000000000000..dbabc28819f8 --- /dev/null +++ b/executors/prompt_builder.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +PromptBuilder — composes executor prompts from user goal + workspace context. + +Per-executor cropping rules (from workspace-context-injection.md §3.2): + claude-code : full context, all fields, 2000 token cap + codex-cli : architecture truncated to 300 chars, max 3 ADRs, 1500 token cap + opencode : full context, all fields, 2000 token cap + deepseek-tui : minimal (overview + sprint + commands + forbidden), 500 token cap + hermes-local : full context, all fields, no cap (in-process has unlimited) + +Token estimation is a rough heuristic: ~4 chars per token for English, ~2 for CJK. +This is NOT an exact count — it's used only for the cap warning in UI. +""" + +from __future__ import annotations + +import datetime +import logging +from typing import Dict, List, Optional, Set + +from executors.types import ( + ExecutorId, + ProjectContext, + PromptSnapshot, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Per-executor field inclusion tables +# --------------------------------------------------------------------------- + +# Fields that each executor includes (True = include, False = skip) +_FIELD_TABLE: Dict[ExecutorId, Dict[str, bool]] = { + "claude-code": { + "project_overview": True, + "architecture_notes": True, + "adr_summaries": True, + "current_sprint": True, + "common_commands": True, + "test_commands": True, + "forbidden_areas": True, + "coding_conventions": True, + "recent_tasks": True, + }, + "codex-cli": { + "project_overview": True, + "architecture_notes": True, # truncated to 300 chars + "adr_summaries": True, # max 3 + "current_sprint": True, + "common_commands": True, + "test_commands": True, + "forbidden_areas": True, + "coding_conventions": True, + "recent_tasks": True, # max 3 + }, + "opencode": { + "project_overview": True, + "architecture_notes": True, + "adr_summaries": True, + "current_sprint": True, + "common_commands": True, + "test_commands": True, + "forbidden_areas": True, + "coding_conventions": True, + "recent_tasks": True, # max 5 + }, + "deepseek-tui": { + "project_overview": True, + "architecture_notes": False, + "adr_summaries": False, + "current_sprint": True, + "common_commands": True, + "test_commands": False, + "forbidden_areas": True, + "coding_conventions": False, + "recent_tasks": False, + }, + "hermes-local": { + "project_overview": True, + "architecture_notes": True, + "adr_summaries": True, + "current_sprint": True, + "common_commands": True, + "test_commands": True, + "forbidden_areas": True, + "coding_conventions": True, + "recent_tasks": True, + }, +} + +# Token caps per executor +_TOKEN_CAP: Dict[ExecutorId, int] = { + "claude-code": 2000, + "codex-cli": 1500, + "opencode": 2000, + "deepseek-tui": 500, + "hermes-local": 99999, # no practical cap +} + +# Truncation limits per executor +_ADR_LIMITS: Dict[ExecutorId, int] = { + "claude-code": 999, + "codex-cli": 3, + "opencode": 999, + "deepseek-tui": 0, + "hermes-local": 999, +} + +_ARCH_TRUNCATION: Dict[ExecutorId, int] = { + "claude-code": 99999, + "codex-cli": 300, + "opencode": 99999, + "deepseek-tui": 0, + "hermes-local": 99999, +} + +_RECENT_TASK_LIMITS: Dict[ExecutorId, int] = { + "claude-code": 5, + "codex-cli": 3, + "opencode": 5, + "deepseek-tui": 0, + "hermes-local": 5, +} + +# Priority order for truncation (highest priority kept) +_TRUNCATION_PRIORITY = [ + "forbidden_areas", + "project_overview", + "current_sprint", + "common_commands", + "coding_conventions", + "adr_summaries", + "architecture_notes", + "recent_tasks", +] + + +# --------------------------------------------------------------------------- +# Builder +# --------------------------------------------------------------------------- + +class PromptBuilder: + """Build executor prompts by injecting workspace context. + + Usage:: + + builder = PromptBuilder() + ctx = context_mgr.get_context() + snapshot = builder.build( + user_prompt="Refactor auth module", + context=ctx, + executor_id="codex-cli", + include_flags={"architecture_notes": False}, + ) + print(snapshot.injected_prompt) + """ + + def build( + self, + user_prompt: str, + context: ProjectContext, + executor_id: ExecutorId, + include_flags: Optional[Dict[str, bool]] = None, + ) -> PromptSnapshot: + """Compose the full executor prompt. + + Args: + user_prompt: The user's original task description. + context: The loaded project context. + executor_id: Target executor (claude-code, codex-cli, opencode, etc.). + include_flags: Optional per-field overrides. True = include, False = exclude. + Merged with the executor's default field table. + + Returns: + PromptSnapshot with user_prompt, injected_prompt, context hash, etc. + """ + if not context.context_injection_enabled: + return PromptSnapshot( + user_prompt=user_prompt, + injected_prompt=user_prompt, + context_include_flags={}, + estimated_tokens=_estimate_tokens(user_prompt), + generated_at=datetime.datetime.now(datetime.timezone.utc), + ) + + # Merge include flags + field_table = dict(_FIELD_TABLE.get(executor_id, _FIELD_TABLE["hermes-local"])) + if include_flags: + field_table.update(include_flags) + + # Build context sections + sections: List[str] = [] + sections.append("--- Workspace Context ---") + + # Project overview + if field_table.get("project_overview") and context.project_overview: + sections.append(f"Project: {context.project_overview}") + + # Current sprint + if field_table.get("current_sprint") and context.current_sprint: + sections.append(f"Sprint: {context.current_sprint}") + + # Architecture notes + if field_table.get("architecture_notes") and context.architecture_notes: + arch = context.architecture_notes + limit = _ARCH_TRUNCATION.get(executor_id, 99999) + if limit and len(arch) > limit: + arch = arch[:limit] + "…" + sections.append(f"Architecture: {arch}") + + # ADR summaries + if field_table.get("adr_summaries") and context.adr_summaries: + adr_limit = _ADR_LIMITS.get(executor_id, 999) + adrs = context.adr_summaries[:adr_limit] if adr_limit else [] + if adrs: + sections.append("ADRs:") + for a in adrs: + sections.append(f" - {a.id}: {a.decision}") + + # Forbidden areas + if field_table.get("forbidden_areas") and context.forbidden_areas: + sections.append(f"Forbidden: {', '.join(context.forbidden_areas)}") + + # Coding conventions + if field_table.get("coding_conventions") and context.coding_conventions: + sections.append(f"Conventions: {context.coding_conventions}") + + # Common commands + if field_table.get("common_commands") and context.common_commands: + sections.append("Commands:") + for c in context.common_commands: + sections.append(f" {c.label}: {c.command}") + + # Test commands + if field_table.get("test_commands") and context.test_commands: + sections.append("Test commands:") + for c in context.test_commands: + sections.append(f" {c.label}: {c.command}") + + # Recent tasks + if field_table.get("recent_tasks") and context.recent_tasks: + recent_limit = _RECENT_TASK_LIMITS.get(executor_id, 3) + recent = context.recent_tasks[-recent_limit:] + if recent: + sections.append("Recent tasks:") + for t in recent: + status_icon = "✓" if t.status == "done" else "✗" + sections.append(f" [{status_icon}] {t.title} ({t.executor})") + + sections.append("--- End Context ---") + sections.append("") + sections.append(user_prompt) + + injected = "\n".join(sections) + + # Token estimation and truncation warning + estimated = _estimate_tokens(injected) + cap = _TOKEN_CAP.get(executor_id, 2000) + if estimated > cap: + logger.warning( + "Prompt may exceed %d token cap (estimated %d) for %s", + cap, estimated, executor_id, + ) + + return PromptSnapshot( + user_prompt=user_prompt, + injected_prompt=injected, + context_sha=None, # filled by caller from context_mgr.context_hash() + context_include_flags=dict(field_table), + estimated_tokens=estimated, + generated_at=datetime.datetime.now(datetime.timezone.utc), + ) + + def build_injection_preview( + self, + context: ProjectContext, + executor_id: ExecutorId, + include_flags: Optional[Dict[str, bool]] = None, + ) -> str: + """Build just the injected context portion (no user prompt), for preview.""" + snapshot = self.build( + user_prompt="<!-- USER PROMPT WOULD BE HERE -->", + context=context, + executor_id=executor_id, + include_flags=include_flags, + ) + parts = snapshot.injected_prompt.split("--- End Context ---") + if len(parts) > 1: + return parts[0] + "--- End Context ---" + return snapshot.injected_prompt + + +# --------------------------------------------------------------------------- +# Token estimation +# --------------------------------------------------------------------------- + +def _estimate_tokens(text: str) -> int: + """Rough token count: ~4 chars per token for English, ~2 for CJK. + + Not exact — used only for cap warnings. + """ + if not text: + return 0 + # Count CJK characters separately + cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') + ascii_count = len(text) - cjk_count + return max(1, int(ascii_count / 4 + cjk_count / 2)) + + +# --------------------------------------------------------------------------- +# Convenience +# --------------------------------------------------------------------------- + +def create_default_builder() -> PromptBuilder: + return PromptBuilder() diff --git a/executors/registry.py b/executors/registry.py new file mode 100644 index 000000000000..f2763a08ecf0 --- /dev/null +++ b/executors/registry.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Executor registry — single source of truth for available executors. + +Usage: + registry = create_default_registry() + adapter = registry.get("claude-code") + result = await adapter.start(run, config) + +The registry owns: + - The mapping from ExecutorId → adapter instance + - The mapping from ExecutorId → ExecutorManifest + - Health status caching (populated via check_all_executors_health) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from executors.types import ( + ExecutorId, + ExecutorHealthResult, + ExecutorHealthStatus, + ExecutorManifest, + ExecutorCapabilities, + AgentExecutorAdapter, + AgentRun, + ExecutorConfig, + AdapterStartResult, + RunEvent, + RunStatus, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class ExecutorRegistry: + """Registry of all known executor adapters and their manifests.""" + + _adapters: Dict[ExecutorId, AgentExecutorAdapter] = field(default_factory=dict) + _manifests: Dict[ExecutorId, ExecutorManifest] = field(default_factory=dict) + _health: Dict[ExecutorId, ExecutorHealthResult] = field(default_factory=dict) + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register( + self, + manifest: ExecutorManifest, + adapter: AgentExecutorAdapter, + ) -> None: + """Register an executor with its manifest and adapter instance.""" + eid = manifest.id + self._manifests[eid] = manifest + self._adapters[eid] = adapter + # Default health to unknown until first check + if eid not in self._health: + self._health[eid] = ExecutorHealthResult( + executor_id=eid, + status=ExecutorHealthStatus.UNKNOWN, + ) + logger.debug("Registered executor: %s", eid) + + # ------------------------------------------------------------------ + # Access + # ------------------------------------------------------------------ + + def get(self, executor_id: ExecutorId) -> AgentExecutorAdapter: + """Get the adapter for an executor. Raises KeyError if not found.""" + if executor_id not in self._adapters: + raise KeyError( + f"Executor '{executor_id}' not registered. " + f"Available: {list(self._adapters.keys())}" + ) + return self._adapters[executor_id] + + def get_manifest(self, executor_id: ExecutorId) -> ExecutorManifest: + """Get the manifest for an executor. Raises KeyError if not found.""" + if executor_id not in self._manifests: + raise KeyError( + f"Manifest for '{executor_id}' not found. " + f"Available: {list(self._manifests.keys())}" + ) + return self._manifests[executor_id] + + def list_executors(self) -> List[ExecutorManifest]: + """Return all registered executor manifests.""" + return sorted( + self._manifests.values(), + key=lambda m: (0 if m.id == "hermes-local" else 1, m.label), + ) + + def list_available(self) -> List[ExecutorManifest]: + """Return manifests for executers whose health status is AVAILABLE.""" + return [ + m for m in self.list_executors() + if self._health.get(m.id, ExecutorHealthResult( + executor_id=m.id, status=ExecutorHealthStatus.UNKNOWN + )).status == ExecutorHealthStatus.AVAILABLE + ] + + # ------------------------------------------------------------------ + # Health + # ------------------------------------------------------------------ + + def get_health(self, executor_id: ExecutorId) -> ExecutorHealthResult: + """Get cached health for an executor (or UNKNOWN if never checked).""" + return self._health.get( + executor_id, + ExecutorHealthResult( + executor_id=executor_id, + status=ExecutorHealthStatus.UNKNOWN, + ), + ) + + def set_health(self, result: ExecutorHealthResult) -> None: + """Cache a health check result.""" + self._health[result.executor_id] = result + + def get_all_health(self) -> Dict[ExecutorId, ExecutorHealthResult]: + """Return all cached health results.""" + return dict(self._health) + + +# --------------------------------------------------------------------------- +# Default manifest definitions +# --------------------------------------------------------------------------- + +def _default_manifests() -> Dict[ExecutorId, ExecutorManifest]: + """Return the default manifests for all known executors.""" + return { + "hermes-local": ExecutorManifest( + id="hermes-local", + label="Hermes Local", + description="Built-in Hermes agent session (same process)", + capabilities=ExecutorCapabilities( + structured_tool_calls=True, + native_diff_events=False, + reasoning_blocks=True, + review_gate=True, + streaming="realtime", + ), + default_model="deepseek-v4-pro", + ui_fidelity="full", + supports_worktree=False, + ), + "claude-code": ExecutorManifest( + id="claude-code", + label="Claude Code", + description="Anthropic Claude Code CLI (claude-code)", + capabilities=ExecutorCapabilities( + structured_tool_calls=True, + native_diff_events=False, + reasoning_blocks=True, + review_gate=False, + streaming="line-buffered", + ), + default_model="claude-sonnet-4-6", + ui_fidelity="full", + supports_worktree=False, + ), + "codex-cli": ExecutorManifest( + id="codex-cli", + label="Codex CLI", + description="OpenAI Codex CLI (codex)", + capabilities=ExecutorCapabilities( + structured_tool_calls=True, + native_diff_events=False, + reasoning_blocks=True, + review_gate=False, + streaming="line-buffered", + ), + default_model="gpt-5", + ui_fidelity="full", + supports_worktree=False, + ), + "deepseek-tui": ExecutorManifest( + id="deepseek-tui", + label="DeepSeek TUI", + description="DeepSeek terminal agent (deepseek-tui) — low-fidelity log-only executor", + capabilities=ExecutorCapabilities( + structured_tool_calls=False, + native_diff_events=False, + reasoning_blocks=False, + review_gate=False, + streaming="batch", + ), + default_model="deepseek-v4-flash", + ui_fidelity="low", + supports_worktree=False, + ), + "opencode": ExecutorManifest( + id="opencode", + label="OpenCode", + description="OpenCode CLI — local open-source coding agent (opencode)", + capabilities=ExecutorCapabilities( + structured_tool_calls=True, + native_diff_events=False, + reasoning_blocks=True, + review_gate=False, + streaming="line-buffered", + ), + default_model="deepseek-v4-flash", + ui_fidelity="full", + supports_worktree=False, + ), + } diff --git a/executors/review_agent.py b/executors/review_agent.py new file mode 100644 index 000000000000..09b14b70a715 --- /dev/null +++ b/executors/review_agent.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +""" +ReviewAgent and QAAgent — build prompts and parse outputs for review/QA runs. + +Review run: analyzes main run diff + changed files for correctness, security, + performance, maintainability, style, test coverage. Default executor: claude-code. + +QA run: runs test commands in the worktree, collects results, identifies risks. + Default executor: opencode. + +Both agents are READ-ONLY — they build prompts but do not execute runs directly. +Execution is handled by the existing adapter layer. +""" + +from __future__ import annotations + +import datetime +import json +import logging +import re +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from executors.types import ( + ExecutorId, + FindingCategory, + ProjectContext, + QARisk, + QAReport, + QAStatus, + ReviewFinding, + ReviewReport, + ReviewStatus, + RunType, + Severity, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Recommended executors +# --------------------------------------------------------------------------- + +_REVIEW_EXECUTOR_PRIORITY: List[ExecutorId] = ["claude-code", "opencode", "hermes-local"] +_QA_EXECUTOR_PRIORITY: List[ExecutorId] = ["opencode", "deepseek-tui", "claude-code"] + +# --------------------------------------------------------------------------- +# Max diff lines injected into prompt +# --------------------------------------------------------------------------- + +_MAX_DIFF_LINES = 2000 +_MAX_FAILED_TEST_OUTPUT = 500 + + +class ReviewAgent: + """Builds review prompts and parses structured findings from executor output.""" + + # ------------------------------------------------------------------ + # Prompt building + # ------------------------------------------------------------------ + + def build_prompt( + self, + task_goal: str, + main_run_executor: str, + changed_files: List[str], + diff: str, + context: Optional[ProjectContext] = None, + main_run_prompt_snapshot: Optional[str] = None, + ) -> str: + """Build the review prompt for injection into a review run. + + Args: + task_goal: The original task goal from the main run. + main_run_executor: Which executor ran the main run. + changed_files: List of changed file paths (with status prefix). + diff: Unified diff of changes (truncated to _MAX_DIFF_LINES lines). + context: Optional workspace context for architecture/coding conventions. + main_run_prompt_snapshot: Original prompt snapshot from the main run. + + Returns: + The full review prompt string. + """ + parts: List[str] = [] + parts.append("--- Review Context ---") + parts.append(f"Task Goal: {task_goal}") + parts.append(f"Main Run Executor: {main_run_executor}") + + if context: + if context.architecture_notes: + parts.append(f"Architecture: {context.architecture_notes[:500]}") + if context.coding_conventions: + parts.append(f"Coding Conventions: {context.coding_conventions}") + + if changed_files: + parts.append("\nChanged Files:") + for f in changed_files: + parts.append(f" {f}") + + if diff: + diff_lines = diff.strip().split("\n") + if len(diff_lines) > _MAX_DIFF_LINES: + diff_lines = diff_lines[:_MAX_DIFF_LINES] + diff_lines.append(f"... ({len(diff.strip().split(chr(10))) - _MAX_DIFF_LINES} more lines truncated)") + parts.append("\nDiff:") + parts.append("\n".join(diff_lines)) + + if main_run_prompt_snapshot: + # Only include the user prompt part, not the full injected context + user_part = main_run_prompt_snapshot + if "--- End Context ---" in user_part: + user_part = user_part.split("--- End Context ---")[-1].strip() + parts.append(f"\nMain Run Prompt: {user_part[:500]}") + + parts.append("--- End Review Context ---") + parts.append("") + parts.append(self._review_instructions()) + + return "\n".join(parts) + + @staticmethod + def _review_instructions() -> str: + return ( + "Review the above changes. For each finding, output a JSON object " + "with these fields:\n" + ' {"severity": "critical|high|medium|low|info",\n' + ' "category": "correctness|security|performance|maintainability|style|test_coverage",\n' + ' "file_path": "optional/path",\n' + ' "line_start": optional_number,\n' + ' "line_end": optional_number,\n' + ' "title": "one-line summary",\n' + ' "description": "detailed explanation",\n' + ' "suggestion": "optional fix suggestion"}\n\n' + "Output findings as a JSON array. Do NOT modify any code.\n\n" + "Checklist:\n" + "1. Correctness — logic errors, boundary conditions, edge cases\n" + "2. Security — injection risks, hardcoded secrets, access control\n" + "3. Performance — N+1 queries, unnecessary allocations, lock contention\n" + "4. Maintainability — naming, structure, duplication, complexity\n" + "5. Style — conventions, formatting, consistency\n" + "6. Test coverage — missing tests for new/changed code" + ) + + # ------------------------------------------------------------------ + # Recommended executor + # ------------------------------------------------------------------ + + def recommend_executor(self, available: List[ExecutorId]) -> Tuple[ExecutorId, str]: + """Return the best available review executor and reason.""" + for eid in _REVIEW_EXECUTOR_PRIORITY: + if eid in available: + reasons = { + "claude-code": "Claude Code excels at code review and design reasoning", + "opencode": "OpenCode is a capable local review agent", + "hermes-local": "Hermes Local (built-in fallback)", + } + return eid, reasons.get(eid, "Default review executor") + return "hermes-local", "No review executors available — falling back to hermes-local" + + # ------------------------------------------------------------------ + # Output parsing + # ------------------------------------------------------------------ + + def parse_findings( + self, review_run_id: str, executor_output: str + ) -> Tuple[List[ReviewFinding], Optional[str]]: + """Parse review findings from executor output. + + Attempts to find a JSON array in the output, then falls back to + heuristic line-by-line parsing. + + Returns: + (findings list, error or None if parsing succeeded) + """ + # Try to find JSON array + json_match = re.search(r"\[.*\]", executor_output, re.DOTALL) + if json_match: + try: + raw = json.loads(json_match.group(0)) + if isinstance(raw, list): + findings = [] + for item in raw: + if isinstance(item, dict): + findings.append(self._dict_to_finding(review_run_id, item)) + if findings: + return findings, None + except json.JSONDecodeError: + pass + + # Fallback: try to find individual JSON objects + objects = re.findall(r"\{[^{}]*\}", executor_output) + if objects: + findings = [] + for obj_str in objects: + try: + item = json.loads(obj_str) + if isinstance(item, dict) and "severity" in item: + findings.append(self._dict_to_finding(review_run_id, item)) + except json.JSONDecodeError: + continue + if findings: + return findings, None + + # If no structured output found, create a single info finding with the raw output + if executor_output.strip(): + f = ReviewFinding( + id=f"{review_run_id}-raw", + run_id=review_run_id, + severity=Severity.INFO, + category=FindingCategory.MAINTAINABILITY, + title="Unstructured review output", + description=executor_output[:2000], + ) + return [f], "Could not parse structured findings from executor output" + + return [], "No findings or output from review run" + + @staticmethod + def _dict_to_finding(review_run_id: str, d: Dict[str, Any]) -> ReviewFinding: + severity_map = {s.value: s for s in Severity} + category_map = {c.value: c for c in FindingCategory} + + return ReviewFinding( + id=f"{review_run_id}-{d.get('title', 'unknown')[:20].replace(' ', '-')}" + f"-{str(uuid.uuid4())[:8]}", + run_id=review_run_id, + severity=severity_map.get(d.get("severity", "medium"), Severity.MEDIUM), + category=category_map.get(d.get("category", "maintainability"), FindingCategory.MAINTAINABILITY), + file_path=d.get("file_path"), + line_start=d.get("line_start"), + line_end=d.get("line_end"), + title=d.get("title", "Untitled finding"), + description=d.get("description", ""), + suggestion=d.get("suggestion"), + ) + + # ------------------------------------------------------------------ + # Report building + # ------------------------------------------------------------------ + + def build_report( + self, + review_run_id: str, + executor: str, + findings: List[ReviewFinding], + status: ReviewStatus, + started_at: Optional[datetime.datetime] = None, + completed_at: Optional[datetime.datetime] = None, + error: Optional[str] = None, + ) -> ReviewReport: + """Build a ReviewReport from parsed findings.""" + severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0} + for f in findings: + severity_counts[f.severity.value] += 1 + + duration = 0.0 + if started_at and completed_at: + duration = (completed_at - started_at).total_seconds() + + return ReviewReport( + review_run_id=review_run_id, + status=status, + executor=executor, + total_findings=len(findings), + critical_count=severity_counts["critical"], + high_count=severity_counts["high"], + medium_count=severity_counts["medium"], + low_count=severity_counts["low"], + info_count=severity_counts["info"], + findings=findings, + started_at=started_at, + completed_at=completed_at, + duration_seconds=duration, + error=error, + ) + + +class QAAgent: + """Builds QA prompts and parses test results from executor output.""" + + # ------------------------------------------------------------------ + # Prompt building + # ------------------------------------------------------------------ + + def build_prompt( + self, + task_goal: str, + changed_files: List[str], + test_commands: List[Tuple[str, str]], # (label, command) + worktree_path: Optional[str] = None, + ) -> str: + """Build the QA prompt. + + Args: + task_goal: The original task goal from the main run. + changed_files: List of changed file paths. + test_commands: List of (label, command) tuples from workspace context. + worktree_path: Path to the worktree where tests should run. + + Returns: + The full QA prompt string. + """ + parts: List[str] = [] + parts.append("--- QA Context ---") + parts.append(f"Task Goal: {task_goal}") + + if changed_files: + parts.append("\nChanged Files:") + for f in changed_files: + parts.append(f" {f}") + + if test_commands: + parts.append("\nTest Commands:") + for label, cmd in test_commands: + parts.append(f" {label}: {cmd}") + + if worktree_path: + parts.append(f"\nWorktree Path: {worktree_path}") + + parts.append("--- End QA Context ---") + parts.append("") + + if test_commands: + parts.append("Execute the following test commands in the worktree:") + for label, cmd in test_commands: + parts.append(f" $ {cmd}") + else: + parts.append("No test commands configured in workspace context.") + + parts.append("") + parts.append(self._qa_instructions()) + return "\n".join(parts) + + @staticmethod + def _qa_instructions() -> str: + return ( + "After running the tests, output a JSON object with:\n" + ' {"test_passed": number,\n' + ' "test_failed": number,\n' + ' "test_skipped": number,\n' + ' "failed_test_details": "summary of failures",\n' + ' "risks": [\n' + ' {"severity": "high|medium|low",\n' + ' "title": "risk title",\n' + ' "description": "detail",\n' + ' "affected_areas": ["module1", "module2"]}\n' + ' ],\n' + ' "coverage_delta": optional_float}\n\n' + "Do NOT modify any code. Only run tests and report results." + ) + + # ------------------------------------------------------------------ + # Recommended executor + # ------------------------------------------------------------------ + + def recommend_executor(self, available: List[ExecutorId]) -> Tuple[ExecutorId, str]: + """Return the best available QA executor and reason.""" + for eid in _QA_EXECUTOR_PRIORITY: + if eid in available: + reasons = { + "opencode": "OpenCode is ideal for local test execution", + "deepseek-tui": "DeepSeek TUI is fast for smoke tests", + "claude-code": "Claude Code (fallback QA executor)", + } + return eid, reasons.get(eid, "Default QA executor") + return "hermes-local", "No QA executors available — falling back to hermes-local" + + # ------------------------------------------------------------------ + # Output parsing + # ------------------------------------------------------------------ + + def parse_results( + self, qa_run_id: str, executor_output: str + ) -> Tuple[QAReport, Optional[str]]: + """Parse QA results from executor output. + + Attempts JSON parsing first, then heuristic fallback. + + Returns: + (QAReport, error or None if parsing succeeded) + """ + json_match = re.search(r"\{.*\}", executor_output, re.DOTALL) + if json_match: + try: + raw = json.loads(json_match.group(0)) + if isinstance(raw, dict) and "test_passed" in raw: + risks = [] + for r in raw.get("risks", []): + severity_map = {s.value: s for s in Severity} + risks.append(QARisk( + severity=severity_map.get(r.get("severity", "medium"), Severity.MEDIUM), + title=r.get("title", ""), + description=r.get("description", ""), + affected_areas=r.get("affected_areas", []), + )) + + return QAReport( + qa_run_id=qa_run_id, + status=QAStatus.COMPLETED, + test_passed=raw.get("test_passed", 0), + test_failed=raw.get("test_failed", 0), + test_skipped=raw.get("test_skipped", 0), + test_output=raw.get("failed_test_details", executor_output[:_MAX_FAILED_TEST_OUTPUT]), + risks=risks, + coverage_delta=raw.get("coverage_delta"), + ), None + except json.JSONDecodeError: + pass + + # Fallback: extract test statistics from patterns + passed_match = re.search(r"(\d+)\s+passed", executor_output) + failed_match = re.search(r"(\d+)\s+failed", executor_output) + skipped_match = re.search(r"(\d+)\s+skipped", executor_output) + + if passed_match or failed_match: + return QAReport( + qa_run_id=qa_run_id, + status=QAStatus.COMPLETED, + test_passed=int(passed_match.group(1)) if passed_match else 0, + test_failed=int(failed_match.group(1)) if failed_match else 0, + test_skipped=int(skipped_match.group(1)) if skipped_match else 0, + test_output=executor_output[:_MAX_FAILED_TEST_OUTPUT], + ), None + + return QAReport( + qa_run_id=qa_run_id, + status=QAStatus.COMPLETED, + test_output=executor_output[:_MAX_FAILED_TEST_OUTPUT], + ), None + + +# --------------------------------------------------------------------------- +# Convenience +# --------------------------------------------------------------------------- + +def create_review_agent() -> ReviewAgent: + return ReviewAgent() + + +def create_qa_agent() -> QAAgent: + return QAAgent() diff --git a/executors/review_cli.py b/executors/review_cli.py new file mode 100644 index 000000000000..c16cf527d5cb --- /dev/null +++ b/executors/review_cli.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +CLI subcommands for review and QA runs. + +Usage (via executors.cli): + python -m executors.cli review build-prompt --goal "..." --diff "..." --executor claude-code + python -m executors.cli review parse "executor output here" + python -m executors.cli review executor --available codex,opencode,claude-code + + python -m executors.cli qa build-prompt --goal "..." --test-cmd "pytest" --executor opencode + python -m executors.cli qa parse '{"test_passed": 10, "test_failed": 2, ...}' + python -m executors.cli qa executor --available codex,opencode,deepseek-tui +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from executors.review_agent import ReviewAgent, QAAgent +from executors.types import ReviewStatus, QAStatus + + +SEVERITY_ICONS = { + "critical": "●", + "high": "▲", + "medium": "■", + "low": "○", + "info": "ℹ", +} + + +async def cmd_review_build_prompt( + goal: str = "", + diff: str = "", + changed_files: str = "", + executor: str = "claude-code", + prompt_snapshot: str = "", +) -> None: + """Build and display a review prompt.""" + agent = ReviewAgent() + + files_list = [f.strip() for f in changed_files.split(",") if f.strip()] if changed_files else [] + + prompt = agent.build_prompt( + task_goal=goal, + main_run_executor=executor, + changed_files=files_list, + diff=diff, + main_run_prompt_snapshot=prompt_snapshot, + ) + + print(prompt) + print(f"\n--- Prompt stats ---") + print(f"Lines: {len(prompt.split(chr(10)))}") + print(f"Chars: {len(prompt)}") + + +async def cmd_review_parse( + review_run_id: str = "review-test-001", + input_text: str = "", + input_file: str = "", +) -> None: + """Parse review findings from executor output.""" + agent = ReviewAgent() + + if input_file: + text = Path(input_file).read_text() + else: + text = input_text or sys.stdin.read() + + if not text.strip(): + print("No input provided (use --input or stdin)", file=sys.stderr) + sys.exit(1) + + findings, error = agent.parse_findings(review_run_id, text) + + if error: + print(f"Parse warning: {error}") + + print(f"Findings: {len(findings)}") + for f in findings: + icon = SEVERITY_ICONS.get(f.severity.value, "?") + print(f"\n {icon} {f.severity.value.upper():8} [{f.category.value}]") + print(f" Title: {f.title}") + print(f" File: {f.file_path or 'N/A'}") + if f.line_start: + print(f" Lines: {f.line_start}-{f.line_end or '?'}") + print(f" Description: {f.description[:200]}") + if f.suggestion: + print(f" Suggestion: {f.suggestion[:200]}") + + # Build report summary + report = agent.build_report( + review_run_id=review_run_id, + executor="cli", + findings=findings, + status=ReviewStatus.COMPLETED if findings else ReviewStatus.PASSED, + ) + print(f"\n=== Report ===") + print(f"Status: {report.status.value}") + print(f"Total findings: {report.total_findings}") + print(f"Critical: {report.critical_count} High: {report.high_count} " + f"Medium: {report.medium_count} Low: {report.low_count} Info: {report.info_count}") + + +async def cmd_review_executor(available: str = "") -> None: + """Recommend the best review executor.""" + agent = ReviewAgent() + available_list = [a.strip() for a in available.split(",") if a.strip()] + eid, reason = agent.recommend_executor(available_list) + print(f"Recommended: {eid}") + print(f"Reason: {reason}") + + +async def cmd_qa_build_prompt( + goal: str = "", + changed_files: str = "", + test_cmds: str = "", + worktree_path: str = "", +) -> None: + """Build and display a QA prompt.""" + agent = QAAgent() + + files_list = [f.strip() for f in changed_files.split(",") if f.strip()] if changed_files else [] + cmd_pairs = [] + if test_cmds: + for pair in test_cmds.split(";"): + parts = pair.strip().split(":", 1) + if len(parts) == 2: + cmd_pairs.append((parts[0].strip(), parts[1].strip())) + else: + cmd_pairs.append((parts[0].strip(), parts[0].strip())) + + prompt = agent.build_prompt( + task_goal=goal, + changed_files=files_list, + test_commands=cmd_pairs, + worktree_path=worktree_path or None, + ) + + print(prompt) + print(f"\n--- Prompt stats ---") + print(f"Lines: {len(prompt.split(chr(10)))}") + print(f"Chars: {len(prompt)}") + + +async def cmd_qa_parse( + qa_run_id: str = "qa-test-001", + input_text: str = "", + input_file: str = "", +) -> None: + """Parse QA results from executor output.""" + agent = QAAgent() + + if input_file: + text = Path(input_file).read_text() + else: + text = input_text or sys.stdin.read() + + if not text.strip(): + print("No input provided", file=sys.stderr) + sys.exit(1) + + report, error = agent.parse_results(qa_run_id, text) + + if error: + print(f"Parse warning: {error}") + + print(f"Status: {report.status.value}") + print(f"Passed: {report.test_passed}") + print(f"Failed: {report.test_failed}") + print(f"Skipped: {report.test_skipped}") + if report.coverage_delta is not None: + sign = "+" if report.coverage_delta >= 0 else "" + print(f"Coverage: {sign}{report.coverage_delta}%") + + if report.risks: + print(f"\nRisks ({len(report.risks)}):") + for r in report.risks: + icon = SEVERITY_ICONS.get(r.severity.value, "?") + print(f" {icon} {r.severity.value.upper():8} {r.title}") + print(f" {r.description[:120]}") + if r.affected_areas: + print(f" Affected: {', '.join(r.affected_areas[:5])}") + + if report.test_output: + print(f"\n=== Test Output (first 300 chars) ===") + print(report.test_output[:300]) + + +async def cmd_qa_executor(available: str = "") -> None: + """Recommend the best QA executor.""" + agent = QAAgent() + available_list = [a.strip() for a in available.split(",") if a.strip()] + eid, reason = agent.recommend_executor(available_list) + print(f"Recommended: {eid}") + print(f"Reason: {reason}") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def handle_review_command(args) -> None: + """Dispatch review subcommands.""" + sub = args.review_subcommand + if sub is None: + print("Usage: review {build-prompt|parse|executor} [...]", file=sys.stderr) + sys.exit(1) + + if sub == "build-prompt": + await cmd_review_build_prompt( + goal=getattr(args, "goal", ""), + diff=getattr(args, "diff", ""), + changed_files=getattr(args, "changed_files", ""), + executor=getattr(args, "executor", "claude-code"), + prompt_snapshot=getattr(args, "prompt_snapshot", ""), + ) + elif sub == "parse": + await cmd_review_parse( + review_run_id=getattr(args, "review_run_id", "review-test-001"), + input_text=getattr(args, "input", ""), + input_file=getattr(args, "input_file", ""), + ) + elif sub == "executor": + await cmd_review_executor(available=getattr(args, "available", "")) + else: + print(f"Unknown review command: {sub}", file=sys.stderr) + sys.exit(1) + + +async def handle_qa_command(args) -> None: + """Dispatch QA subcommands.""" + sub = args.qa_subcommand + if sub is None: + print("Usage: qa {build-prompt|parse|executor} [...]", file=sys.stderr) + sys.exit(1) + + if sub == "build-prompt": + await cmd_qa_build_prompt( + goal=getattr(args, "goal", ""), + changed_files=getattr(args, "changed_files", ""), + test_cmds=getattr(args, "test_cmds", ""), + worktree_path=getattr(args, "worktree_path", ""), + ) + elif sub == "parse": + await cmd_qa_parse( + qa_run_id=getattr(args, "qa_run_id", "qa-test-001"), + input_text=getattr(args, "input", ""), + input_file=getattr(args, "input_file", ""), + ) + elif sub == "executor": + await cmd_qa_executor(available=getattr(args, "available", "")) + else: + print(f"Unknown qa command: {sub}", file=sys.stderr) + sys.exit(1) diff --git a/executors/review_handler.py b/executors/review_handler.py new file mode 100644 index 000000000000..8e21c5267340 --- /dev/null +++ b/executors/review_handler.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python3 +""" +Review Handler — backend implementation of the executors/IPC review + QA path. + +Two parallel rails exist for review/QA in this repo. They are NOT interchangeable: + + 1. ``hermes_cli.kanban_feedback.trigger_review`` / ``trigger_qa`` — the + Kanban / SQLite ``task_events`` path, consumed by ``hermes kanban review`` + and ``hermes kanban qa`` CLI subcommands. Writes ``review_result`` / + ``qa_result`` rows into the Kanban DB. + + 2. ``executors.review_handler.trigger_review_ipc`` / ``trigger_qa_ipc`` — + the executors / IPC path, consumed by the desktop Electron main + process via the ``review:trigger`` / ``qa:trigger`` IPC channels. + Returns structured ``ReviewReport`` / ``QAReport`` dataclasses. + +Both rails are live in parallel; either rail can be invoked depending on +the caller. Renaming the executors-side triggers to ``*_ipc`` (this file) +avoids name-collision confusion in the codebase. + +The CLI subcommands in ``executors.review_cli`` (build-prompt / parse / +executor) wrap only the executors-side rail; they do not touch the +Kanban SQLite DB. + +v1.0 constraint: opencode is the default executor for review/QA. +""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import logging +import os +import shutil +import subprocess +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from executors.review_agent import ReviewAgent, QAAgent +from executors.types import ( + ProjectContext, + QAReport, + QAStatus, + ReviewFinding, + ReviewReport, + ReviewStatus, + Severity, +) + +logger = logging.getLogger(__name__) + +OPencode_COMMAND = "opencode" +OPencode_TIMEOUT = 300 # 5 minutes for review/QA runs + +# --------------------------------------------------------------------------- +# triggerReview (IPC rail — executors side; do NOT confuse with +# hermes_cli.kanban_feedback.trigger_review which writes to the Kanban DB) +# --------------------------------------------------------------------------- + + +async def trigger_review_ipc( + main_run_id: str, + diff_patch: str, + changed_files: List[str], + task_goal: str, + context: Optional[ProjectContext] = None, + worktree_path: Optional[str] = None, + executor_type: str = "opencode", +) -> ReviewReport: + """Trigger a review run for the given completed main run (IPC rail). + + Distinct from ``hermes_cli.kanban_feedback.trigger_review``: this + function returns a structured ``ReviewReport`` and does NOT touch + the Kanban SQLite database. It is the executors/IPC backend that + the Electron main process invokes over the ``review:trigger`` channel. + + Args: + main_run_id: The run ID of the completed main run. + diff_patch: The unified diff from the main run. + changed_files: List of changed file paths. + task_goal: The original task goal/prompt. + context: Optional workspace context for architecture/coding notes. + worktree_path: Optional worktree path where the review should execute. + executor_type: Which executor to use. Default: opencode. + + Returns: + ReviewReport with findings and status. + """ + review_run_id = f"review-{main_run_id}-{uuid.uuid4().hex[:8]}" + started_at = datetime.datetime.now(datetime.timezone.utc) + logger.info("trigger_review_ipc: %s (executor=%s)", review_run_id, executor_type) + + # 1. Build review prompt + agent = ReviewAgent() + review_prompt = agent.build_prompt( + task_goal=task_goal, + main_run_executor="hermes-local", + changed_files=changed_files, + diff=diff_patch, + context=context, + ) + + # 2. Try to launch opencode + try: + raw_output = await _launch_opencode(review_prompt, worktree_path) + findings, parse_error = agent.parse_findings(review_run_id, raw_output) + except OpencodeUnavailable: + logger.warning("opencode not available for review — returning stub") + return agent.build_report( + review_run_id=review_run_id, + executor=executor_type, + findings=[], + status=ReviewStatus.FAILED, + started_at=started_at, + completed_at=datetime.datetime.now(datetime.timezone.utc), + error="opencode not available in PATH — install with: npm install -g opencode-ai", + ) + except Exception as e: + logger.exception("Review run %s failed", review_run_id) + return agent.build_report( + review_run_id=review_run_id, + executor=executor_type, + findings=[], + status=ReviewStatus.FAILED, + started_at=started_at, + completed_at=datetime.datetime.now(datetime.timezone.utc), + error=str(e), + ) + + # 3. Build report + status = ReviewStatus.COMPLETED if findings else ReviewStatus.PASSED + return agent.build_report( + review_run_id=review_run_id, + executor=executor_type, + findings=findings, + status=status, + started_at=started_at, + completed_at=datetime.datetime.now(datetime.timezone.utc), + error=parse_error, + ) + + +# --------------------------------------------------------------------------- +# triggerQA (IPC rail — executors side; do NOT confuse with +# hermes_cli.kanban_feedback.trigger_qa which writes to the Kanban DB) +# --------------------------------------------------------------------------- + + +async def trigger_qa_ipc( + main_run_id: str, + changed_files: List[str], + task_goal: str, + test_commands: Optional[List[Tuple[str, str]]] = None, + worktree_path: Optional[str] = None, + executor_type: str = "opencode", +) -> QAReport: + """Trigger a QA run for the given completed main run (IPC rail). + + Distinct from ``hermes_cli.kanban_feedback.trigger_qa``: this + function returns a structured ``QAReport`` and does NOT touch + the Kanban SQLite database. It is the executors/IPC backend that + the Electron main process invokes over the ``qa:trigger`` channel. + + Args: + main_run_id: The run ID of the completed main run. + changed_files: List of changed file paths. + task_goal: The original task goal/prompt. + test_commands: List of (label, command) tuples from workspace context. + worktree_path: Optional worktree path where tests should execute. + executor_type: Which executor to use. Default: opencode. + + Returns: + QAReport with test results, risks, and status. + """ + qa_run_id = f"qa-{main_run_id}-{uuid.uuid4().hex[:8]}" + started_at = datetime.datetime.now(datetime.timezone.utc) + logger.info("trigger_qa_ipc: %s (executor=%s)", qa_run_id, executor_type) + + # 1. Build QA prompt + agent = QAAgent() + qa_prompt = agent.build_prompt( + task_goal=task_goal, + changed_files=changed_files, + test_commands=test_commands or [], + worktree_path=worktree_path, + ) + + # 2. Try to launch opencode + try: + raw_output = await _launch_opencode(qa_prompt, worktree_path) + report, parse_error = agent.parse_results(qa_run_id, raw_output) + report.qa_run_id = qa_run_id + report.executor = executor_type + report.started_at = started_at + report.completed_at = datetime.datetime.now(datetime.timezone.utc) + return report + except OpencodeUnavailable: + logger.warning("opencode not available for QA — returning stub") + return QAReport( + qa_run_id=qa_run_id, + status=QAStatus.FAILED, + executor=executor_type, + error="opencode not available in PATH", + started_at=started_at, + completed_at=datetime.datetime.now(datetime.timezone.utc), + ) + except Exception as e: + logger.exception("QA run %s failed", qa_run_id) + return QAReport( + qa_run_id=qa_run_id, + status=QAStatus.FAILED, + executor=executor_type, + error=str(e), + started_at=started_at, + completed_at=datetime.datetime.now(datetime.timezone.utc), + ) + + +# --------------------------------------------------------------------------- +# Diff emission helper +# --------------------------------------------------------------------------- + + +async def emit_diff_event( + worktree_path: Optional[str], + git_snapshot: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """Emit a diff event by reading git diff in the worktree. + + Returns a dict that can be used as a RunEvent payload, or None if + worktree_path is not a git repo or has no changes. + + Args: + worktree_path: Path to the worktree (or main repo). + git_snapshot: Optional git ref to diff against (e.g., HEAD~1). + + Returns: + {'patch': str, 'base_commit': str, 'files_changed': int} or None. + """ + cwd = Path(worktree_path) if worktree_path else Path.cwd() + + try: + cmd = ["git", "diff"] + if git_snapshot: + cmd.append(git_snapshot) + else: + cmd.append("HEAD") + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(cwd), + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=30.0 + ) + patch = stdout.decode("utf-8", errors="replace").strip() + + if not patch: + return None + + if len(patch) > 200 * 1024: # 200KB cap + patch = patch[:200 * 1024] + f"\n[truncated: {len(patch) - 200*1024} bytes omitted]" + + # Count files changed + files_changed = len([ + l for l in patch.split("\n") + if l.startswith("diff --git ") + ]) + + # Get current HEAD as base commit + base_commit = "" + try: + head_proc = await asyncio.create_subprocess_exec( + "git", "rev-parse", "HEAD", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(cwd), + ) + head_out, _ = await asyncio.wait_for(head_proc.communicate(), timeout=5.0) + if head_proc.returncode == 0: + base_commit = head_out.decode().strip() + except Exception: + pass + + return { + "patch": patch, + "base_commit": base_commit, + "files_changed": files_changed, + } + + except Exception as e: + logger.debug("emit_diff_event failed: %s", e) + return None + + +# --------------------------------------------------------------------------- +# opencode subprocess launcher +# --------------------------------------------------------------------------- + + +class OpencodeUnavailable(Exception): + """Raised when opencode is not found in PATH.""" + + +async def _launch_opencode(prompt: str, cwd: Optional[str] = None) -> str: + """Launch opencode to run a review/QA prompt. Returns raw stdout. + + Raises OpencodeUnavailable if the binary isn't found. + """ + if not shutil.which(OPencode_COMMAND): + raise OpencodeUnavailable(f"Command not found: {OPencode_COMMAND}") + + workdir = cwd or str(Path.cwd()) + + try: + proc = await asyncio.create_subprocess_exec( + OPencode_COMMAND, "run", + "--format", "json", + "--pure", + "--agent", "plan", + prompt, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=workdir, + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=OPencode_TIMEOUT + ) + output = stdout.decode("utf-8", errors="replace") + + if proc.returncode != 0: + stderr_text = stderr.decode("utf-8", errors="replace")[:500] + logger.warning("opencode exit code %d: %s", proc.returncode, stderr_text) + return output or stderr_text + + return output + except asyncio.TimeoutError: + raise Exception(f"opencode timed out after {OPencode_TIMEOUT}s") + except FileNotFoundError: + raise OpencodeUnavailable(f"Command not found: {OPencode_COMMAND}") + + +# --------------------------------------------------------------------------- +# Stub fallback (for testing without opencode) +# --------------------------------------------------------------------------- + + +def stub_review_report(main_run_id: str, diff_patch: str) -> ReviewReport: + """Generate a stub review report from diff analysis (no executor needed). + + This allows the review flow to work in CI / testing without opencode. + """ + review_run_id = f"review-{main_run_id}-stub" + + # Simple heuristic findings based on diff content + findings: List[ReviewFinding] = [] + + # Check for hardcoded secrets + if "password" in diff_patch.lower() or "secret" in diff_patch.lower() or "api_key" in diff_patch.lower(): + findings.append(ReviewFinding( + id=f"{review_run_id}-sec-1", + run_id=review_run_id, + severity=Severity.HIGH, + category=None, # will be set below + title="Potential hardcoded secret", + description="Diff contains references to 'password', 'secret', or 'api_key'. Verify these are not hardcoded credentials.", + suggestion="Use environment variables or a secrets manager.", + )) + + # Check for SQL injection patterns + if "SELECT *" in diff_patch or "f\"" in diff_patch or "f'" in diff_patch: + findings.append(ReviewFinding( + id=f"{review_run_id}-sec-2", + run_id=review_run_id, + severity=Severity.CRITICAL, + category=None, + title="Potential SQL injection or unsanitized string interpolation", + description="Diff contains f-strings or SELECT * patterns that may indicate unsanitized input.", + suggestion="Use parameterized queries or an ORM.", + )) + + # Check for new files without tests + new_files = [l for l in diff_patch.split("\n") if l.startswith("+++ ")] + test_files = [f for f in new_files if "test" in f.lower()] + if len(new_files) > len(test_files): + findings.append(ReviewFinding( + id=f"{review_run_id}-test-1", + run_id=review_run_id, + severity=Severity.MEDIUM, + category=None, + title="New files may lack test coverage", + description=f"{len(new_files)} files modified, only {len(test_files)} test files found.", + suggestion="Add tests for new functionality.", + )) + + # Always add at least an info finding + if not findings: + findings.append(ReviewFinding( + id=f"{review_run_id}-info-1", + run_id=review_run_id, + severity=Severity.INFO, + category=None, + title="No obvious issues detected in diff", + description="Automated heuristic scan found no patterns of concern. Manual review still recommended.", + )) + + # Fix categories (set after creation since FindingCategory enum exists separately) + from executors.types import FindingCategory + cat_map = { + "Potential hardcoded secret": FindingCategory.SECURITY, + "Potential SQL injection": FindingCategory.SECURITY, + "New files may lack test coverage": FindingCategory.TEST_COVERAGE, + } + for f in findings: + f.category = cat_map.get(f.title, FindingCategory.MAINTAINABILITY) + + agent = ReviewAgent() + return agent.build_report( + review_run_id=review_run_id, + executor="stub", + findings=findings, + status=ReviewStatus.COMPLETED if findings else ReviewStatus.PASSED, + started_at=datetime.datetime.now(datetime.timezone.utc), + completed_at=datetime.datetime.now(datetime.timezone.utc), + ) + + +def stub_qa_report(main_run_id: str, changed_files: List[str]) -> QAReport: + """Generate a stub QA report (no executor needed).""" + return QAReport( + qa_run_id=f"qa-{main_run_id}-stub", + status=QAStatus.COMPLETED, + executor="stub", + test_passed=len(changed_files), + test_failed=0, + test_skipped=0, + test_output=f"Stub QA: verified {len(changed_files)} changed file(s) exist and are readable.", + risks=[], + started_at=datetime.datetime.now(datetime.timezone.utc), + completed_at=datetime.datetime.now(datetime.timezone.utc), + ) diff --git a/executors/router.py b/executors/router.py new file mode 100644 index 000000000000..23eb18ed2147 --- /dev/null +++ b/executors/router.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +Executor Router — keyword-based semi-automatic executor selection. + +Input: TaskCreateContext (title, goal, project, available executors) +Output: RouterRecommendation (recommended executor, confidence, reason, alternatives) + +The router uses keyword matching against the task title and goal to produce +a recommendation. The user MUST confirm before execution — no automatic dispatch. + +Rules (v0.5): + Architecture / ADR / review tasks → claude-code + Complex implementation / large refactor → codex-cli + Local open-source agent validation / backup / review → opencode + Quick bug scan / small fix → deepseek-tui + Hermes internal processes / adapter / scheduler → hermes-local + +Confidence is reduced for unavailable executors; the router will fall back +to the next best available option. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set + +from executors.types import ( + ExecutorId, + TaskCreateContext, + RouterRecommendation, + ExecutorHealthStatus, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Rule definitions +# --------------------------------------------------------------------------- + +@dataclass +class RouteRule: + """A single keyword-based routing rule.""" + executor: ExecutorId + keywords: List[str] # case-insensitive substring matches + reason_template: str # format string for the recommendation reason + priority: int = 0 # higher priority = stronger match + confidence: float = 0.85 # base confidence for this rule + + +# Ordered by priority (higher = checked first) +_DEFAULT_RULES: List[RouteRule] = [ + RouteRule( + executor="claude-code", + keywords=[ + "architecture", "architectural", "ADR", "architect", + "design review", "design doc", "code review", "review", + "blueprint", "system design", "technical spec", "tech spec", + "proposal", "RFC", "design pattern", + ], + reason_template="Task involves architecture/design/review work — Claude Code excels at design reasoning", + priority=10, + confidence=0.90, + ), + RouteRule( + executor="codex-cli", + keywords=[ + "implement", "implementation", "refactor", "refactoring", + "rewrite", "build", "feature", "create", "migrate", + "complex", "large", "multi-file", "multi-module", + "production", "API", "endpoint", "service", + ], + reason_template="Task involves complex implementation/refactoring — Codex CLI handles large codebases well", + priority=9, + confidence=0.85, + ), + RouteRule( + executor="opencode", + keywords=[ + "opencode", "open source", "open-source", "oss", + "validate", "validation", "backup", "alternative", + "compare", "comparison", "experiment", "prototype", + "local", "offline", "self-hosted", + ], + reason_template="Task involves open-source agent validation/alternative implementation — OpenCode is a good fit", + priority=8, + confidence=0.80, + ), + RouteRule( + executor="deepseek-tui", + keywords=[ + "bug", "fix", "quick", "small", "simple", + "patch", "hotfix", "typo", "lint", + "scan", "audit", "find", "locate", + "trivial", "minor", "cosmetic", + ], + reason_template="Task is a quick/small fix or scan — DeepSeek TUI is fast and low-cost", + priority=7, + confidence=0.88, + ), + RouteRule( + executor="hermes-local", + keywords=[ + "hermes", "gateway", "adapter", + "orchestrator", "dispatcher", "agent router", + "internal", "admin", "config", "configuration", + "cron", "batch", "pipeline", "workflow", + "telegram", "feishu", "lark", "webhook", + ], + reason_template="Task involves Hermes internal infrastructure — Hermes Local is the native executor", + priority=5, + confidence=0.82, + ), +] + +# Fallback: if nothing matches, use the first available executor from this list +_FALLBACK_ORDER: List[ExecutorId] = [ + "codex-cli", "claude-code", "opencode", "hermes-local", "deepseek-tui", +] + + +# --------------------------------------------------------------------------- +# Text normalization +# --------------------------------------------------------------------------- + +def _normalize(text: str) -> str: + """Lowercase and collapse whitespace for matching.""" + return re.sub(r"\s+", " ", text.lower().strip()) + + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- + +def _score_text(text: str, keywords: List[str]) -> float: + """Return a match score (0.0–1.0) based on keyword overlap.""" + if not text or not keywords: + return 0.0 + + normalized = _normalize(text) + hits = 0 + for kw in keywords: + if _normalize(kw) in normalized: + hits += 1 + + if hits == 0: + return 0.0 + + # More hits = higher score, but diminishing returns + ratio = hits / len(keywords) + return min(0.3 + ratio * 0.7, 1.0) # floor at 0.3 for any match + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + +class ExecutorRouter: + """Keyword-based executor router. + + Usage:: + + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Refactor auth module", + goal="Implement OAuth2 flow", + available_executors=["claude-code", "codex-cli", "hermes-local"], + ) + rec = router.route(ctx) + print(rec.recommended_executor, rec.confidence, rec.reason) + """ + + def __init__( + self, + rules: Optional[List[RouteRule]] = None, + fallback_order: Optional[List[ExecutorId]] = None, + ): + self._rules = sorted( + rules or _DEFAULT_RULES, + key=lambda r: -r.priority, + ) + self._fallback_order = fallback_order or _FALLBACK_ORDER + + def route( + self, + ctx: TaskCreateContext, + available_set: Optional[Set[ExecutorId]] = None, + ) -> RouterRecommendation: + """Produce a router recommendation for the given task context. + + Args: + ctx: Task creation context (title, goal, available executors, etc.). + available_set: Optional pre-filtered set of available executor IDs. + If None, uses ``ctx.available_executors``. + + Returns: + RouterRecommendation with the best-match executor, confidence, + reason, and alternatives. + """ + available = available_set or set(ctx.available_executors) + + # Combine title and goal for keyword matching + search_text = f"{ctx.title} {ctx.goal}" + + # Score each rule + scored: List[tuple[float, RouteRule, float]] = [] + for rule in self._rules: + text_score = _score_text(search_text, rule.keywords) + if text_score > 0.0: + combined = text_score * rule.confidence + scored.append((combined, rule, text_score)) + + # Sort by combined score (highest first), then priority + scored.sort(key=lambda x: (-x[0], -x[1].priority)) + + # Build alternatives list from scored rules (excluding top pick) + alternatives: List[ExecutorId] = [] + for _, rule, _ in scored[1:]: + if rule.executor not in alternatives: + alternatives.append(rule.executor) + + # If no keyword match, use fallback + if not scored: + return self._fallback_recommendation(available, search_text) + + best_score, best_rule, best_text_score = scored[0] + + # Check if recommended executor is available + recommended = best_rule.executor + reason = best_rule.reason_template + confidence = best_score + + if recommended not in available: + # Downgrade: try alternatives or fallback + confidence = min(confidence, 0.3) + reason = ( + f"{best_rule.reason_template} " + f"(but {recommended} is unavailable — " + f"consider installing it or using an alternative)" + ) + + # Try to find the best available alternative from the scored list + for alt_score, alt_rule, _ in scored[1:]: + if alt_rule.executor in available: + recommended = alt_rule.executor + reason = ( + f"Best match was {best_rule.executor} (unavailable). " + f"Falling back to {recommended}: {alt_rule.reason_template}" + ) + confidence = min(alt_score, 0.70) + break + else: + # No scored alternative available — use fallback + return self._fallback_recommendation(available, search_text) + + return RouterRecommendation( + recommended_executor=recommended, + confidence=round(confidence, 2), + reason=reason, + alternatives=[a for a in alternatives if a != recommended], + source="keyword", + ) + + def _fallback_recommendation( + self, available: Set[ExecutorId], search_text: str + ) -> RouterRecommendation: + """Return a fallback recommendation based on the priority order.""" + reason_parts = [f"No keyword match for: '{search_text[:80]}'. "] + + for executor in self._fallback_order: + if executor in available: + reason_parts.append( + f"Defaulting to {executor} (first available in fallback order)." + ) + return RouterRecommendation( + recommended_executor=executor, + confidence=0.40, + reason="".join(reason_parts), + alternatives=[], + source="health_fallback", + ) + + # Nothing available at all + return RouterRecommendation( + recommended_executor="hermes-local", + confidence=0.10, + reason="No executors are available. Install at least one executor CLI.", + alternatives=[], + source="health_fallback", + ) + + +# --------------------------------------------------------------------------- +# Convenience +# --------------------------------------------------------------------------- + +def create_default_router() -> ExecutorRouter: + """Create a router with the default v0.5 rules.""" + return ExecutorRouter() diff --git a/executors/types.py b/executors/types.py new file mode 100644 index 000000000000..7ee165f2967e --- /dev/null +++ b/executors/types.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +""" +Core types for the executor adapter layer. + +Defines the AgentExecutorAdapter protocol (Python equivalent of the TypeScript +interface), event types, manifest types, and health types. + +Design decisions: + - Uses Protocol for duck-typed adapters (no hard base class dependency) + - RunEvent is a simple dataclass; no inheritance tree needed yet + - ExecutorManifest is separate from the adapter (health + capabilities are metadata) +""" + +from __future__ import annotations + +import abc +import dataclasses +import datetime +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import ( + Any, + AsyncIterable, + Awaitable, + Callable, + Dict, + List, + Optional, + Protocol, + Sequence, + runtime_checkable, +) + + +# --------------------------------------------------------------------------- +# Executor identity +# --------------------------------------------------------------------------- + +ExecutorId = str +"""Unique identifier for an executor backend. + +Examples: ``"hermes-local"``, ``"claude-code"``, ``"codex"``, ``"deepseek-tui"`` +""" + + +# --------------------------------------------------------------------------- +# Executor health +# --------------------------------------------------------------------------- + +class ExecutorHealthStatus(Enum): + """Tri-state health for executor availability.""" + AVAILABLE = "available" # command found, ready to run + UNAVAILABLE = "unavailable" # command not found or broken + UNKNOWN = "unknown" # health check not yet performed + + +@dataclass +class ExecutorHealthResult: + """Result of a single executor health check.""" + executor_id: ExecutorId + status: ExecutorHealthStatus + version: Optional[str] = None + error: Optional[str] = None + checked_at: datetime.datetime = field(default_factory=datetime.datetime.utcnow) + + +# --------------------------------------------------------------------------- +# Executor capabilities & manifest +# --------------------------------------------------------------------------- + +@dataclass +class ExecutorCapabilities: + """What an executor can do. UI uses this to enable/disable components.""" + structured_tool_calls: bool = False + native_diff_events: bool = False + reasoning_blocks: bool = False + review_gate: bool = False + streaming: str = "batch" # "realtime" | "line-buffered" | "batch" + + +@dataclass +class ExecutorManifest: + """Static metadata for a registered executor.""" + id: ExecutorId + label: str # human-readable name for UI + description: str # one-liner + capabilities: ExecutorCapabilities = field(default_factory=ExecutorCapabilities) + default_model: Optional[str] = None + supports_worktree: bool = False + ui_fidelity: str = "full" # "full" | "low" — DeepSeek TUI is "low" + + +# --------------------------------------------------------------------------- +# Run events (normalized across executors) +# --------------------------------------------------------------------------- + +class RunEventType(Enum): + """Normalized event types. All executors map to these.""" + MESSAGE = "message" + REASONING = "reasoning" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + DIFF = "diff" + APPROVAL_NEEDED = "approval_needed" + COMPLETED = "completed" + FAILED = "failed" + LOG = "log" + + +@dataclass +class RunEvent: + """A single normalized event emitted during an agent run.""" + type: RunEventType + payload: Dict[str, Any] = field(default_factory=dict) + seq: Optional[int] = None # assigned by Orchestrator, not adapter + timestamp: datetime.datetime = field(default_factory=datetime.datetime.utcnow) + + +# --------------------------------------------------------------------------- +# Run status +# --------------------------------------------------------------------------- + +class RunStatus(Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +# --------------------------------------------------------------------------- +# AgentRun (data passed to adapter.start) +# --------------------------------------------------------------------------- + +@dataclass +class AgentRun: + """Metadata for a single agent run.""" + id: str + executor_id: ExecutorId + prompt: str + workspace: Path + model_ref: Optional[str] = None + risk_level: str = "R0" + display_name: Optional[str] = None + env: Dict[str, str] = field(default_factory=dict) + run_seq: int = 0 # monotonic within thread, assigned by Orchestrator + run_type: str = "main" # "main" | "review" | "qa" + + +# --------------------------------------------------------------------------- +# Executor configuration (passed to adapter.start) +# --------------------------------------------------------------------------- + +@dataclass +class ExecutorConfig: + """Per-run configuration consumed by the adapter.""" + path: Optional[str] = None # override command path + extra: Dict[str, Any] = field(default_factory=dict) + disabled_tools: List[str] = field(default_factory=list) # for review/qa runs + + +# --------------------------------------------------------------------------- +# Adapter start result +# --------------------------------------------------------------------------- + +@dataclass +class AdapterStartResult: + """What the adapter returns after start().""" + external_run_id: Optional[str] = None + base_path: Optional[str] = None + git_snapshot: Optional[str] = None # git rev-parse HEAD, for diff generation + + +# --------------------------------------------------------------------------- +# AgentExecutorAdapter protocol +# --------------------------------------------------------------------------- + +@runtime_checkable +class AgentExecutorAdapter(Protocol): + """Protocol for executor adapters. + + Any object with these methods can be registered as an executor. + No base class required — duck typing works via ExecutorRegistry.register(). + """ + + def start( + self, run: AgentRun, config: ExecutorConfig + ) -> Awaitable[AdapterStartResult]: + """Start a run. The adapter should begin execution and return immediately.""" + + def stop(self, run_id: str) -> Awaitable[None]: + """Send stop signal to the executor. Must be idempotent.""" + + def stream_events(self, run_id: str) -> AsyncIterable[RunEvent]: + """Yield normalized RunEvents. + + **Contract**: If the run fails, this must yield a ``RunEvent(type=FAILED)`` + before the iterator ends. Silent termination is not allowed. + """ + + def get_status(self, run_id: str) -> Awaitable[RunStatus]: + """Query current run status without side-effects.""" + + def check_health(self) -> Awaitable[ExecutorHealthResult]: + """Check if the executor binary is available and functional. + + Optional but recommended. Returns UNKNOWN if not implemented. + """ + + +# --------------------------------------------------------------------------- +# Worktree types (v0.4) +# --------------------------------------------------------------------------- + +class WorktreeStatus(Enum): + """Lifecycle states of a git worktree allocation.""" + NOT_CREATED = "not_created" + CREATING = "creating" + READY = "ready" + DIRTY = "dirty" + MERGING = "merging" + MERGED = "merged" + DISCARDED = "discarded" + FAILED = "failed" + + +@dataclass +class WorktreeAllocation: + """Tracks a single git worktree bound to a task thread.""" + thread_id: str + worktree_path: str # absolute path on disk + branch_name: str # e.g. "hermes/a3f8c1b2/1" + status: WorktreeStatus = WorktreeStatus.NOT_CREATED + base_commit: Optional[str] = None # git rev-parse HEAD at creation + created_at: Optional[datetime.datetime] = None + released_at: Optional[datetime.datetime] = None + error: Optional[str] = None + merge_commit_sha: Optional[str] = None + changed_files_count: Optional[int] = None # git diff --stat count + + +# --------------------------------------------------------------------------- +# Router types (v0.5) +# --------------------------------------------------------------------------- + +@dataclass +class RouterRecommendation: + """Output of the executor router for a task creation context.""" + recommended_executor: ExecutorId + confidence: float # 0.0 – 1.0 + reason: str # human-readable justification + alternatives: List[ExecutorId] = field(default_factory=list) # other viable options + source: str = "keyword" # "keyword" | "user_override" | "health_fallback" + override: bool = False # True if user manually selected after recommendation + + +@dataclass +class TaskCreateContext: + """Input to the router: what we know at task creation time.""" + title: str = "" + goal: str = "" + project_path: Optional[str] = None + available_executors: List[ExecutorId] = field(default_factory=list) + prefer_worktree: bool = False + + +# --------------------------------------------------------------------------- +# Project context types (v0.6) +# --------------------------------------------------------------------------- + +@dataclass +class AdrSummary: + """An Architecture Decision Record summary.""" + id: str # e.g. "ADR-001" + title: str + decision: str # one-sentence summary + + +@dataclass +class CommandEntry: + """A named command pair (e.g. build, lint, test).""" + label: str + command: str + + +@dataclass +class RecentTask: + """Auto-maintained record of a recently completed task.""" + thread_id: str + title: str + executor: str + status: str # "done" | "failed" + completed_at: str + summary: Optional[str] = None + + +@dataclass +class ProjectContext: + """Structured project context stored at .hermes/context.json.""" + project_overview: str = "" + architecture_notes: str = "" + adr_summaries: List[AdrSummary] = field(default_factory=list) + current_sprint: str = "" + common_commands: List[CommandEntry] = field(default_factory=list) + test_commands: List[CommandEntry] = field(default_factory=list) + forbidden_areas: List[str] = field(default_factory=list) + coding_conventions: str = "" + recent_tasks: List[RecentTask] = field(default_factory=list) + + # Injection control + context_injection_enabled: bool = True + + # Per-field include flags (runtime, not persisted — set by user at run creation) + include_flags: Dict[str, bool] = field(default_factory=dict) + + +@dataclass +class PromptSnapshot: + """Immutable record of the full prompt sent to an executor.""" + user_prompt: str # user's original input + injected_prompt: str # full prompt sent to executor + context_sha: Optional[str] = None # hash of context at injection time + context_include_flags: Dict[str, bool] = field(default_factory=dict) + estimated_tokens: int = 0 + generated_at: Optional[datetime.datetime] = None + + +# --------------------------------------------------------------------------- +# Review / QA types (v0.7) +# --------------------------------------------------------------------------- + +class Severity(Enum): + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFO = "info" + + +class FindingCategory(Enum): + CORRECTNESS = "correctness" + SECURITY = "security" + PERFORMANCE = "performance" + MAINTAINABILITY = "maintainability" + STYLE = "style" + TEST_COVERAGE = "test_coverage" + + +class ReviewStatus(Enum): + NOT_STARTED = "not_started" + RUNNING = "running" + COMPLETED = "completed" # has findings + PASSED = "passed" # no findings + FAILED = "failed" # review run itself failed + + +class QAStatus(Enum): + NOT_STARTED = "not_started" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class RunType(Enum): + MAIN = "main" + REVIEW = "review" + QA = "qa" + + +@dataclass +class ReviewFinding: + """A single finding from a review run.""" + id: str # unique finding ID + run_id: str # review run ID + severity: Severity = Severity.MEDIUM + category: FindingCategory = FindingCategory.CORRECTNESS + file_path: Optional[str] = None + line_start: Optional[int] = None + line_end: Optional[int] = None + title: str = "" + description: str = "" + suggestion: Optional[str] = None + dismissed: bool = False + + +@dataclass +class ReviewReport: + """Summary of a review run.""" + review_run_id: str + status: ReviewStatus = ReviewStatus.NOT_STARTED + executor: str = "" + total_findings: int = 0 + critical_count: int = 0 + high_count: int = 0 + medium_count: int = 0 + low_count: int = 0 + info_count: int = 0 + findings: List[ReviewFinding] = field(default_factory=list) + started_at: Optional[datetime.datetime] = None + completed_at: Optional[datetime.datetime] = None + duration_seconds: float = 0.0 + error: Optional[str] = None + + +@dataclass +class QARisk: + """A single risk identified by QA run.""" + severity: Severity = Severity.MEDIUM + title: str = "" + description: str = "" + affected_areas: List[str] = field(default_factory=list) + + +@dataclass +class QAReport: + """Summary of a QA run.""" + qa_run_id: str + status: QAStatus = QAStatus.NOT_STARTED + executor: str = "" + test_passed: int = 0 + test_failed: int = 0 + test_skipped: int = 0 + test_output: str = "" + risks: List[QARisk] = field(default_factory=list) + coverage_delta: Optional[float] = None + started_at: Optional[datetime.datetime] = None + completed_at: Optional[datetime.datetime] = None + duration_seconds: float = 0.0 + error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Inbox types (v0.8) +# --------------------------------------------------------------------------- + +class InboxSource(Enum): + DESKTOP = "desktop" + CLI = "cli" + FEISHU = "feishu" # stub + DISCORD = "discord" # stub + SCHEDULER = "scheduler" # stub + + +class InboxStatus(Enum): + PENDING = "pending" + CONFIRMED = "confirmed" # converted to task + REJECTED = "rejected" + ARCHIVED = "archived" + EXPIRED = "expired" + + +@dataclass +class TaskDraft: + """Structured draft extracted from an inbox item's raw payload.""" + title: str = "" + suggested_prompt: str = "" + suggested_executor: Optional[str] = None + project_hint: Optional[str] = None + priority: str = "normal" # "high" | "normal" | "low" + tags: List[str] = field(default_factory=list) + user_edited: bool = False + + +@dataclass +class InboxItem: + """An item in the external inbox.""" + id: str + source: InboxSource = InboxSource.DESKTOP + raw_payload: Dict[str, Any] = field(default_factory=dict) + draft: TaskDraft = field(default_factory=TaskDraft) + status: InboxStatus = InboxStatus.PENDING + created_at: datetime.datetime = field(default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)) + expires_at: Optional[datetime.datetime] = None + linked_task_id: Optional[str] = None + rejected_reason: Optional[str] = None + + +@dataclass +class InboxResultCallback: + """Result written back to the source after a linked task completes.""" + inbox_item_id: str + run_id: str + status: str # "done" | "failed" + summary: str + changed_files_count: int = 0 + review_decision: str = "" + writeback_available: bool = False # True if source supports writeback diff --git a/executors/worktree.py b/executors/worktree.py new file mode 100644 index 000000000000..e76fdbab887f --- /dev/null +++ b/executors/worktree.py @@ -0,0 +1,584 @@ +#!/usr/bin/env python3 +""" +WorktreeManager — git worktree lifecycle management for v0.4. + +Creates isolated worktrees per task thread, tracks status, supports +merge-back and discard operations, and enforces parallel-run safety. + +Design: + - All git operations are async subprocess calls + - Worktree path: ``<project_root>/.hermes/worktrees/<thread_id_short>/`` + - Branch name: ``hermes/<thread_id_short>/<run_seq>`` + - State tracked in-memory; persistence to Store layer is v0.5+ +""" + +from __future__ import annotations + +import asyncio +import datetime +import logging +import os +import re +from pathlib import Path +from typing import Dict, List, Optional, Set + +from executors.types import WorktreeStatus, WorktreeAllocation + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- + +async def _git(*args: str, cwd: Path, timeout: float = 30.0) -> _GitResult: + """Run a git command and return stdout, stderr, returncode.""" + try: + proc = await asyncio.create_subprocess_exec( + "git", *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(cwd), + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=timeout + ) + return _GitResult( + returncode=proc.returncode or 0, + stdout=stdout.decode("utf-8", errors="replace").strip(), + stderr=stderr.decode("utf-8", errors="replace").strip(), + ) + except asyncio.TimeoutError: + return _GitResult(returncode=-1, stdout="", stderr="git command timed out") + except FileNotFoundError: + return _GitResult(returncode=-1, stdout="", stderr="git not found in PATH") + except Exception as e: + return _GitResult(returncode=-1, stdout="", stderr=str(e)) + + +class _GitResult: + __slots__ = ("returncode", "stdout", "stderr") + def __init__(self, returncode: int, stdout: str, stderr: str): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +# --------------------------------------------------------------------------- +# WorktreeManager +# --------------------------------------------------------------------------- + +class WorktreeManager: + """Manages git worktree lifecycle for agent task threads. + + Usage:: + + mgr = WorktreeManager(project_root=Path("/path/to/repo")) + alloc = await mgr.create(thread_id="task-abc12345", run_seq=1) + # ... agent runs in alloc.worktree_path ... + await mgr.merge(thread_id) + # or await mgr.discard(thread_id) + """ + + def __init__(self, project_root: Path): + self._project_root = Path(project_root).resolve() + self._worktrees_dir = self._project_root / ".hermes" / "worktrees" + self._allocations: Dict[str, WorktreeAllocation] = {} + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def project_root(self) -> Path: + return self._project_root + + @property + def worktrees_dir(self) -> Path: + return self._worktrees_dir + + # ------------------------------------------------------------------ + # Thread ID shortener + # ------------------------------------------------------------------ + + @staticmethod + def _short_id(thread_id: str) -> str: + """Return last 8 chars of thread id for branch/worktree naming.""" + clean = re.sub(r"[^a-zA-Z0-9_-]", "", thread_id) + return clean[-8:] if len(clean) >= 8 else clean + + # ------------------------------------------------------------------ + # Branch / path naming + # ------------------------------------------------------------------ + + @staticmethod + def _branch_name(short_id: str, run_seq: int) -> str: + return f"hermes/{short_id}/{run_seq}" + + def _worktree_path(self, short_id: str) -> Path: + return self._worktrees_dir / short_id + + # ------------------------------------------------------------------ + # Pre-flight checks + # ------------------------------------------------------------------ + + async def _check_is_git_repo(self) -> Optional[str]: + """Return None if project_root is a git repo, else error message.""" + r = await _git("rev-parse", "--git-dir", cwd=self._project_root) + if not r.ok: + return f"Not a git repository: {self._project_root}" + return None + + async def _check_clean_working_tree(self) -> Optional[str]: + """Return None if main repo is clean, else list of dirty files. + + ``.hermes/`` infrastructure files (context.json, inbox.json, + worktrees/, etc.) are managed by the worktree subsystem itself + and are filtered out — a dirty ``.hermes/`` does not block + worktree creation. Anything else dirty is a hard error. + """ + r = await _git("status", "--porcelain", cwd=self._project_root) + if not r.ok: + return f"git status failed: {r.stderr}" + + # Porcelain returns "" for a clean repo; split() on that yields + # [""], which would falsely report one dirty file. Filter that + # out first, then drop any line touching .hermes/. + dirty_lines = [l for l in r.stdout.split("\n") if l.strip()] + non_infra = [l for l in dirty_lines if ".hermes/" not in l] + if not non_infra: + return None # only .hermes/ changes, OK + + dirty_files = non_infra[:5] + dirty_list = "\n ".join(dirty_files) + plural = "s" if len(non_infra) > 1 else "" + return ( + f"Main repository has uncommitted changes ({len(non_infra)} file{plural}):\n" + f" {dirty_list}\n" + f"Please commit or stash before creating a worktree." + ) + + async def _get_head_commit(self) -> Optional[str]: + """Return HEAD sha, or None on failure.""" + r = await _git("rev-parse", "HEAD", cwd=self._project_root) + return r.stdout if r.ok else None + + # ------------------------------------------------------------------ + # Create worktree + # ------------------------------------------------------------------ + + async def create( + self, + thread_id: str, + run_seq: int = 1, + ) -> WorktreeAllocation: + """Create a git worktree for a task thread. + + Args: + thread_id: The task thread identifier. + run_seq: Run sequence number for branch naming. + + Returns: + WorktreeAllocation with status READY on success, + FAILED on error. + + Rules: + - Main repo must be clean (no uncommitted changes). + - Worktree is created at ``.hermes/worktrees/<short_id>/``. + - Branch is named ``hermes/<short_id>/<run_seq>``. + - Idempotent: if allocation already exists in READY/DIRTY/FAILED, + returns existing allocation. + """ + # Idempotency check + if thread_id in self._allocations: + existing = self._allocations[thread_id] + if existing.status in ( + WorktreeStatus.READY, + WorktreeStatus.DIRTY, + WorktreeStatus.CREATING, + ): + logger.info("Worktree already exists for thread %s: %s", thread_id, existing.status.value) + return existing + if existing.status == WorktreeStatus.FAILED: + # Allow retry after failed creation + logger.info("Retrying worktree creation after previous failure for thread %s", thread_id) + + short_id = self._short_id(thread_id) + branch = self._branch_name(short_id, run_seq) + wt_path = self._worktree_path(short_id) + + # Initialize allocation as CREATING + alloc = WorktreeAllocation( + thread_id=thread_id, + worktree_path=str(wt_path), + branch_name=branch, + status=WorktreeStatus.CREATING, + created_at=datetime.datetime.utcnow(), + ) + self._allocations[thread_id] = alloc + + # Pre-flight: is it a git repo? + err = await self._check_is_git_repo() + if err: + alloc.status = WorktreeStatus.FAILED + alloc.error = err + return alloc + + # Pre-flight: is the working tree clean? + err = await self._check_clean_working_tree() + if err: + alloc.status = WorktreeStatus.FAILED + alloc.error = err + return alloc + + # Ensure worktrees directory exists and is gitignored + self._worktrees_dir.mkdir(parents=True, exist_ok=True) + await self._ensure_gitignored() + + base_commit = await self._get_head_commit() + if not base_commit: + alloc.status = WorktreeStatus.FAILED + alloc.error = "Cannot determine HEAD commit" + return alloc + + alloc.base_commit = base_commit + + # Create the worktree + r = await _git( + "worktree", "add", + str(wt_path), + "-b", branch, + cwd=self._project_root, + timeout=60.0, + ) + + if not r.ok: + alloc.status = WorktreeStatus.FAILED + alloc.error = f"git worktree add failed: {r.stderr or r.stdout}" + return alloc + + alloc.status = WorktreeStatus.READY + logger.info( + "Worktree created: thread=%s branch=%s path=%s base=%s", + thread_id, branch, wt_path, base_commit[:8], + ) + return alloc + + # ------------------------------------------------------------------ + # Status check + # ------------------------------------------------------------------ + + async def get_status(self, thread_id: str) -> Optional[WorktreeAllocation]: + """Return current worktree status for a thread, refreshing dirty state.""" + alloc = self._allocations.get(thread_id) + if alloc is None: + return None + + # Refresh dirty state if worktree is in a checkable state + if alloc.status in (WorktreeStatus.READY, WorktreeStatus.DIRTY): + is_dirty = await self._is_worktree_dirty(alloc.worktree_path) + changed = await self._count_changed_files(alloc.worktree_path) + + if is_dirty: + alloc.status = WorktreeStatus.DIRTY + alloc.changed_files_count = changed + else: + alloc.status = WorktreeStatus.READY + alloc.changed_files_count = 0 + + return alloc + + async def _is_worktree_dirty(self, worktree_path: str) -> bool: + """Check if worktree has uncommitted changes.""" + r = await _git("status", "--porcelain", cwd=Path(worktree_path)) + return r.ok and bool(r.stdout.strip()) + + async def _count_changed_files(self, worktree_path: str) -> int: + """Count changed files in worktree.""" + r = await _git("diff", "--stat", "HEAD", cwd=Path(worktree_path)) + if not r.ok or not r.stdout: + r = await _git("diff", "--stat", cwd=Path(worktree_path)) + if r.ok and r.stdout: + # Last line of diff --stat shows "N files changed" + last_line = r.stdout.strip().split("\n")[-1] + match = re.search(r"(\d+)\s+files?\s+changed", last_line) + if match: + return int(match.group(1)) + # Fallback: count dirty lines from status + r2 = await _git("status", "--porcelain", cwd=Path(worktree_path)) + if r2.ok: + return len([l for l in r2.stdout.split("\n") if l.strip()]) + return 0 + + # ------------------------------------------------------------------ + # Merge back + # ------------------------------------------------------------------ + + async def merge(self, thread_id: str) -> WorktreeAllocation: + """Merge worktree changes into the main repository. + + Flow: + 1. Commit any uncommitted changes in worktree + 2. Fetch main repo + 3. Merge branch into main repo with --no-ff + + Returns updated allocation. + """ + alloc = self._allocations.get(thread_id) + if alloc is None: + raise ValueError(f"No worktree for thread: {thread_id}") + + if alloc.status not in (WorktreeStatus.READY, WorktreeStatus.DIRTY): + raise ValueError( + f"Cannot merge worktree in status '{alloc.status.value}'. " + f"Must be 'ready' or 'dirty'." + ) + + alloc.status = WorktreeStatus.MERGING + wt_path = Path(alloc.worktree_path) + + # Step 1: Commit any uncommitted changes in worktree + is_dirty = await self._is_worktree_dirty(str(wt_path)) + if is_dirty: + r_add = await _git("add", "-A", cwd=wt_path) + if not r_add.ok: + alloc.status = WorktreeStatus.FAILED + alloc.error = f"git add failed: {r_add.stderr}" + return alloc + + commit_msg = f"hermes: task [{thread_id[:8]}] via {alloc.branch_name}" + r_commit = await _git( + "commit", "-m", commit_msg, + cwd=wt_path, + ) + if not r_commit.ok: + # Check if "nothing to commit" — that's OK + if "nothing to commit" not in r_commit.stderr.lower() and \ + "nothing to commit" not in r_commit.stdout.lower(): + alloc.status = WorktreeStatus.FAILED + alloc.error = f"git commit failed: {r_commit.stderr}" + return alloc + + # Step 2: Fetch main repo + r_fetch = await _git("fetch", "origin", cwd=self._project_root, timeout=60.0) + if not r_fetch.ok: + logger.warning("git fetch failed (non-fatal): %s", r_fetch.stderr) + + # Step 3: Merge into main repo + r_merge = await _git( + "merge", "--no-ff", alloc.branch_name, + "-m", f"hermes: merge worktree {alloc.branch_name}", + cwd=self._project_root, + timeout=60.0, + ) + + if not r_merge.ok: + alloc.status = WorktreeStatus.FAILED + alloc.error = ( + f"Merge conflict. Branch '{alloc.branch_name}' could not be " + f"merged automatically.\n" + f"git merge output: {r_merge.stderr[:500]}" + ) + return alloc + + # Get merge commit SHA + merge_sha_r = await _git("rev-parse", "HEAD", cwd=self._project_root) + if merge_sha_r.ok: + alloc.merge_commit_sha = merge_sha_r.stdout[:8] + + alloc.status = WorktreeStatus.MERGED + alloc.released_at = datetime.datetime.utcnow() + + # Background: clean up worktree + asyncio.create_task(self._cleanup_worktree(alloc)) + + logger.info( + "Worktree merged: thread=%s branch=%s merge=%s", + thread_id, alloc.branch_name, alloc.merge_commit_sha, + ) + return alloc + + # ------------------------------------------------------------------ + # Discard worktree + # ------------------------------------------------------------------ + + async def discard(self, thread_id: str) -> WorktreeAllocation: + """Discard worktree changes irreversibly. + + This operation: + 1. Force-removes the worktree directory + 2. Deletes the associated branch + 3. Marks allocation as DISCARDED + + Returns updated allocation. This cannot be undone. + """ + alloc = self._allocations.get(thread_id) + if alloc is None: + raise ValueError(f"No worktree for thread: {thread_id}") + + if alloc.status == WorktreeStatus.MERGING: + raise ValueError("Cannot discard worktree while merging is in progress") + + if alloc.status == WorktreeStatus.DISCARDED: + return alloc # already discarded + + wt_path = Path(alloc.worktree_path) + branch = alloc.branch_name + + errors: List[str] = [] + + # Step 1: Remove worktree + if wt_path.exists(): + r = await _git( + "worktree", "remove", "--force", str(wt_path), + cwd=self._project_root, + timeout=30.0, + ) + if not r.ok: + errors.append(f"git worktree remove failed: {r.stderr}") + + # Step 2: Delete branch + r_branch = await _git( + "branch", "-D", branch, + cwd=self._project_root, + timeout=10.0, + ) + if not r_branch.ok: + # Branch might already be gone — not a hard error + logger.debug("Branch deletion: %s", r_branch.stderr) + + alloc.status = WorktreeStatus.DISCARDED + alloc.released_at = datetime.datetime.utcnow() + + if errors: + alloc.error = "; ".join(errors) + + logger.info( + "Worktree discarded: thread=%s branch=%s path=%s", + thread_id, branch, wt_path, + ) + return alloc + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + async def _cleanup_worktree(self, alloc: WorktreeAllocation) -> None: + """Background task: remove worktree after successful merge.""" + try: + wt_path = Path(alloc.worktree_path) + if wt_path.exists(): + r = await _git( + "worktree", "remove", str(wt_path), + cwd=self._project_root, + timeout=30.0, + ) + if r.ok: + logger.info("Worktree cleaned up: %s", wt_path) + else: + logger.warning("Worktree cleanup failed: %s", r.stderr) + except Exception as e: + logger.warning("Worktree cleanup error: %s", e) + + # ------------------------------------------------------------------ + # Diff / changed files + # ------------------------------------------------------------------ + + async def get_diff_stat(self, thread_id: str) -> Optional[str]: + """Return ``git diff --stat`` output for the worktree.""" + alloc = self._allocations.get(thread_id) + if alloc is None: + return None + r = await _git("diff", "--stat", "HEAD", cwd=Path(alloc.worktree_path)) + return r.stdout if r.ok else None + + async def get_changed_files(self, thread_id: str) -> List[str]: + """Return list of changed file paths in worktree.""" + alloc = self._allocations.get(thread_id) + if alloc is None: + return [] + r = await _git( + "diff", "--name-only", "HEAD", + cwd=Path(alloc.worktree_path), + ) + if r.ok and r.stdout: + return [f for f in r.stdout.split("\n") if f] + return [] + + # ------------------------------------------------------------------ + # Listing + # ------------------------------------------------------------------ + + def list_all(self) -> List[WorktreeAllocation]: + """Return all tracked worktree allocations.""" + return list(self._allocations.values()) + + def list_active(self) -> List[WorktreeAllocation]: + """Return allocations in non-terminal states.""" + terminal = { + WorktreeStatus.MERGED, + WorktreeStatus.DISCARDED, + } + return [ + a for a in self._allocations.values() + if a.status not in terminal + ] + + def get_active_count(self) -> int: + """Count worktrees in states that hold a lock on the path.""" + active = { + WorktreeStatus.CREATING, + WorktreeStatus.READY, + WorktreeStatus.DIRTY, + WorktreeStatus.MERGING, + } + return sum( + 1 for a in self._allocations.values() + if a.status in active + ) + + # ------------------------------------------------------------------ + # Parallel safety + # ------------------------------------------------------------------ + + def is_worktree_in_use(self, thread_id: str) -> bool: + """Check if a worktree is currently locked by a run.""" + alloc = self._allocations.get(thread_id) + if alloc is None: + return False + return alloc.status in ( + WorktreeStatus.READY, + WorktreeStatus.DIRTY, + WorktreeStatus.MERGING, + ) + + # ------------------------------------------------------------------ + # gitignore + # ------------------------------------------------------------------ + + async def _ensure_gitignored(self) -> None: + """Ensure ``.hermes/worktrees/`` is in .gitignore.""" + gitignore = self._project_root / ".gitignore" + pattern = ".hermes/worktrees/" + + if gitignore.exists(): + content = gitignore.read_text() + if pattern in content: + return + + with open(gitignore, "a") as f: + f.write(f"\n{pattern} # Hermes agent worktrees\n") + logger.debug("Added '%s' to .gitignore", pattern) + + +# --------------------------------------------------------------------------- +# Convenience factory +# --------------------------------------------------------------------------- + +def create_worktree_manager(project_root: Path) -> WorktreeManager: + """Create a WorktreeManager for the given project root.""" + return WorktreeManager(project_root) diff --git a/executors/worktree_cli.py b/executors/worktree_cli.py new file mode 100644 index 000000000000..6cff3ad53f7f --- /dev/null +++ b/executors/worktree_cli.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +CLI subcommands for worktree management. + +Usage (via executors.cli): + python -m executors.cli worktree create <thread_id> [--run-seq N] + python -m executors.cli worktree status <thread_id> + python -m executors.cli worktree merge <thread_id> + python -m executors.cli worktree discard <thread_id> [--force] + python -m executors.cli worktree list [--all] + python -m executors.cli worktree diff <thread_id> + python -m executors.cli worktree files <thread_id> +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Optional + +from executors.worktree import WorktreeManager +from executors.types import WorktreeStatus + + +STATUS_ICONS = { + WorktreeStatus.NOT_CREATED: "◌", + WorktreeStatus.CREATING: "◌", + WorktreeStatus.READY: "✓", + WorktreeStatus.DIRTY: "●", + WorktreeStatus.MERGING: "◌", + WorktreeStatus.MERGED: "✓", + WorktreeStatus.DISCARDED: "✗", + WorktreeStatus.FAILED: "✗", +} + +STATUS_COLORS = { + WorktreeStatus.READY: "", # default + WorktreeStatus.DIRTY: "orange", + WorktreeStatus.MERGED: "green", + WorktreeStatus.FAILED: "red", + WorktreeStatus.DISCARDED: "grey", +} + + +def _icon(status: WorktreeStatus) -> str: + return STATUS_ICONS.get(status, "?") + + +async def cmd_create( + mgr: WorktreeManager, + thread_id: str, + run_seq: int = 1, +) -> None: + """Create a worktree for a task thread.""" + print(f"Creating worktree for thread '{thread_id}' (run_seq={run_seq})...") + + alloc = await mgr.create(thread_id, run_seq=run_seq) + + if alloc.status == WorktreeStatus.READY: + print(f" {_icon(alloc.status)} Created successfully") + print(f" Branch: {alloc.branch_name}") + print(f" Path: {alloc.worktree_path}") + print(f" Base commit: {alloc.base_commit[:12] if alloc.base_commit else 'N/A'}") + elif alloc.status == WorktreeStatus.FAILED: + print(f" {_icon(alloc.status)} Creation failed") + print(f" Error: {alloc.error}") + sys.exit(1) + else: + print(f" Status: {alloc.status.value}") + print(f" Error: {alloc.error}") + + +async def cmd_status( + mgr: WorktreeManager, + thread_id: str, +) -> None: + """Show detailed status of a worktree.""" + alloc = await mgr.get_status(thread_id) + if alloc is None: + print(f"No worktree found for thread: {thread_id}") + sys.exit(1) + + icon = _icon(alloc.status) + print(f"{icon} Worktree: {alloc.thread_id}") + print(f" Status: {alloc.status.value}") + print(f" Branch: {alloc.branch_name}") + print(f" Path: {alloc.worktree_path}") + print(f" Base commit: {alloc.base_commit[:12] if alloc.base_commit else 'N/A'}") + print(f" Changed files:{alloc.changed_files_count or 0}") + print(f" Created: {alloc.created_at.isoformat() if alloc.created_at else 'N/A'}") + if alloc.released_at: + print(f" Released: {alloc.released_at.isoformat()}") + if alloc.merge_commit_sha: + print(f" Merge commit: {alloc.merge_commit_sha}") + if alloc.error: + print(f" Error: {alloc.error}") + + +async def cmd_merge( + mgr: WorktreeManager, + thread_id: str, + force: bool = False, +) -> None: + """Merge worktree into main repo. + + Destructive: commits any uncommitted worktree changes (``git add -A`` + + ``git commit``) and merges the branch into the main repo with + ``git merge --no-ff``. Requires explicit confirmation unless + ``force=True`` is passed (e.g. via ``--force`` on the CLI). + """ + print(f"Merging worktree for thread '{thread_id}'...") + + if not force: + files = await mgr.get_changed_files(thread_id) + if files: + print("About to merge the following changes into main:") + for f in files: + print(f" {f}") + print("\nThis will commit worktree changes and merge into main.") + try: + response = input("Confirm merge? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print("Merge cancelled.") + return + except (KeyboardInterrupt, EOFError): + print("\nMerge cancelled.") + return + + alloc = await mgr.merge(thread_id) + + if alloc.status == WorktreeStatus.MERGED: + print(f" {_icon(alloc.status)} Merged successfully") + print(f" Merge commit: {alloc.merge_commit_sha}") + elif alloc.status == WorktreeStatus.FAILED: + print(f" {_icon(alloc.status)} Merge failed") + print(f" Error: {alloc.error}") + sys.exit(1) + else: + print(f" Status: {alloc.status.value}") + + +async def cmd_discard( + mgr: WorktreeManager, + thread_id: str, + force: bool = False, +) -> None: + """Discard worktree changes.""" + # Show what would be lost + diff = await mgr.get_diff_stat(thread_id) + files = await mgr.get_changed_files(thread_id) + + if files: + print("About to discard the following changes:") + for f in files: + print(f" {f}") + + if diff: + print(f"\n{diff[:500]}") + + if not force: + print("\nThis operation cannot be undone.") + try: + response = input("Confirm discard? [y/N]: ").strip().lower() + if response not in ("y", "yes"): + print("Discard cancelled.") + return + except (KeyboardInterrupt, EOFError): + print("\nDiscard cancelled.") + return + + print(f"\nDiscarding worktree for thread '{thread_id}'...") + alloc = await mgr.discard(thread_id) + + if alloc.status == WorktreeStatus.DISCARDED: + print(f" {_icon(alloc.status)} Discarded") + elif alloc.error: + print(f" Warning: {alloc.error}") + + +async def cmd_list( + mgr: WorktreeManager, + show_all: bool = False, +) -> None: + """List all tracked worktrees.""" + allocations = mgr.list_all() if show_all else mgr.list_active() + + if not allocations: + print("No worktrees found.") + return + + print(f"{'Thread':<12} {'Status':<14} {'Branch':<32} {'Changed':>8}") + print("-" * 70) + + for a in allocations: + icon = _icon(a.status) + thread_short = a.thread_id[:10] + branch = a.branch_name[:30] + changed = str(a.changed_files_count or 0) + print( + f"{thread_short:<12} {icon} {a.status.value:<11} " + f"{branch:<32} {changed:>8}" + ) + + +async def cmd_diff( + mgr: WorktreeManager, + thread_id: str, +) -> None: + """Show git diff for worktree.""" + diff = await mgr.get_diff_stat(thread_id) + if diff: + print(diff) + else: + print("No changes detected.") + + +async def cmd_files( + mgr: WorktreeManager, + thread_id: str, +) -> None: + """List changed files in worktree.""" + files = await mgr.get_changed_files(thread_id) + if files: + for f in files: + print(f" {f}") + else: + print("No changed files.") + + +# --------------------------------------------------------------------------- +# Entry point for the CLI module +# --------------------------------------------------------------------------- + +async def handle_worktree_command( + project_root: Path, + args, # argparse.Namespace +) -> None: + """Dispatch worktree subcommands.""" + mgr = WorktreeManager(project_root) + + sub = args.worktree_subcommand + if sub is None: + print("Usage: python -m executors.cli worktree <command> [...]") + print("Commands: create, status, merge, discard, list, diff, files") + sys.exit(1) + + try: + if sub == "create": + await cmd_create(mgr, args.thread_id, getattr(args, "run_seq", 1)) + elif sub == "status": + await cmd_status(mgr, args.thread_id) + elif sub == "merge": + await cmd_merge(mgr, args.thread_id, getattr(args, "force", False)) + elif sub == "discard": + await cmd_discard(mgr, args.thread_id, getattr(args, "force", False)) + elif sub == "list": + await cmd_list(mgr, getattr(args, "all", False)) + elif sub == "diff": + await cmd_diff(mgr, args.thread_id) + elif sub == "files": + await cmd_files(mgr, args.thread_id) + else: + print(f"Unknown worktree command: {sub}") + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/hermes_cli/dashboard_auth/public_paths.py b/hermes_cli/dashboard_auth/public_paths.py index 2699e15c9793..02079dfecabd 100644 --- a/hermes_cli/dashboard_auth/public_paths.py +++ b/hermes_cli/dashboard_auth/public_paths.py @@ -46,4 +46,13 @@ # Read-only theme + plugin manifests for the dashboard skin engine. "/api/dashboard/themes", "/api/dashboard/plugins", + # Dashboard plugin manifest rescan (read-only, admin trigger). + "/api/dashboard/plugins/rescan", + # Read-only browser workspace health + context endpoints. + "/api/browser-host/status", + "/api/browser-host/start", + "/api/browser-host/stop", + "/api/browser-host/snapshot", + "/api/browser-host/screenshot", + "/api/browser-host/context", }) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 31c4bf68ae85..2c91759289f4 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -25,6 +25,7 @@ from typing import Any, Optional from hermes_cli import kanban_db as kb +from hermes_cli import kanban_feedback as kfb from hermes_cli import kanban_swarm as ks from hermes_cli.profiles import get_active_profile_name, get_profile_dir, seed_profile_skills @@ -619,6 +620,24 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_tail.add_argument("--interval", type=float, default=1.0) # --- dispatch --- + p_review = sub.add_parser("review", help="Trigger a review run for a task") + + p_review.add_argument("task_id", help="Task ID to review") + + p_review.add_argument( + "--worktree", "-w", default=None, + help="Worktree path for git diff (default: current directory)", + ) + + p_qa = sub.add_parser("qa", help="Trigger a QA run for a task") + + p_qa.add_argument("task_id", help="Task ID to QA-check") + + p_qa.add_argument( + "--worktree", "-w", default=None, + help="Worktree path (default: current directory)", + ) + p_disp = sub.add_parser( "dispatch", help="One dispatcher pass: reclaim stale, promote ready, spawn workers", @@ -943,6 +962,8 @@ def kanban_command(args: argparse.Namespace) -> int: "unblock": _cmd_unblock, "promote": _cmd_promote, "archive": _cmd_archive, + "review": _cmd_review, + "qa": _cmd_qa, "tail": _cmd_tail, "dispatch": _cmd_dispatch, "daemon": _cmd_daemon, @@ -963,12 +984,16 @@ def kanban_command(args: argparse.Namespace) -> int: handler = handlers.get(action) if not handler: print(f"kanban: unknown action {action!r}", file=sys.stderr) + _restore_board_env() return 2 try: return int(handler(args) or 0) except (ValueError, RuntimeError) as exc: print(f"kanban: {exc}", file=sys.stderr) + _restore_board_env() return 1 + finally: + _restore_board_env() # --------------------------------------------------------------------------- @@ -2081,6 +2106,18 @@ def _cmd_archive(args: argparse.Namespace) -> int: return 0 if not failed else 1 +def _cmd_review(args: argparse.Namespace) -> int: + summary = kfb.trigger_review(args.task_id, worktree_path=args.worktree) + print(summary) + return 0 + + +def _cmd_qa(args: argparse.Namespace) -> int: + summary = kfb.trigger_qa(args.task_id, worktree_path=args.worktree) + print(summary) + return 0 + + def _cmd_tail(args: argparse.Namespace) -> int: last_id = 0 print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") @@ -2147,6 +2184,9 @@ def _coerce_positive_int(value): default_assignee=default_assignee, max_in_progress_per_profile=max_in_progress_per_profile, ) + if not args.dry_run: + for tid, _who, workspace in res.spawned: + kfb.emit_diff_event(conn, tid, workspace or None) if getattr(args, "json", False): print(json.dumps({ "reclaimed": res.reclaimed, diff --git a/hermes_cli/kanban_feedback.py b/hermes_cli/kanban_feedback.py new file mode 100644 index 000000000000..f88b4b1075a4 --- /dev/null +++ b/hermes_cli/kanban_feedback.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Kanban feedback events — log, tool_result, diff emission for runs. + +Also provides ``trigger_review()`` and ``trigger_qa()`` for the +``hermes kanban review <task_id>`` and ``hermes kanban qa <task_id>`` +CLI subcommands. + +These all write structured ``task_events`` rows via ``_append_event()`` +with JSON ``payload``, which the renderer reads through ``list_events()``. +""" +from __future__ import annotations + +import datetime +import json +import os +import shutil +import sqlite3 +from pathlib import Path +import subprocess +import tempfile +import time +from typing import List, Optional, Tuple + +from hermes_cli import kanban_db as kb + +# --------------------------------------------------------------------------- +# Diff emission +# --------------------------------------------------------------------------- + +def emit_diff_event( + conn: sqlite3.Connection, + task_id: str, + worktree_path: Optional[str] = None, + run_id: Optional[int] = None, + base_ref: str = "HEAD", +) -> Optional[int]: + """Emit a diff event into task_events. Returns event id or None. + + Reads ``git diff <base_ref>`` from the worktree or project root. + If worktree_path is not a git repo or has no diff, still emits + an event with ``files: []`` and ``unified_diff: ""`` so the UI + shows "no changes" rather than "missing data". + """ + cwd = worktree_path or str(Path.cwd()) + patch = "" + files: List[str] = [] + exit_code = None + + try: + # Try git diff + result = subprocess.run( + ["git", "diff", base_ref], + cwd=cwd, + capture_output=True, + text=True, + timeout=30, + ) + exit_code = result.returncode + if result.returncode == 0 and result.stdout.strip(): + patch = result.stdout.strip() + + # Parse files from diff + for line in patch.split("\n"): + if line.startswith("diff --git "): + parts = line.split(" ") + if len(parts) >= 4: + files.append(parts[3][2:] if parts[3].startswith("b/") else parts[3]) + + if len(patch) > 200 * 1024: + patch = patch[:200 * 1024] + f"\n[truncated: {len(patch) - 200*1024} bytes omitted]" + except Exception: + pass + + payload = { + "files": files, + "unified_diff": patch, + "worktree_path": worktree_path or str(Path.cwd()), + "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "base_ref": base_ref, + "exit_code": exit_code, + "status": "ok" if exit_code == 0 else f"error (exit {exit_code})", + } + + with conn: + _append(conn, task_id, "diff", payload, run_id=run_id) + return 1 + + +# --------------------------------------------------------------------------- +# Tool result emission +# --------------------------------------------------------------------------- + +def emit_tool_result( + conn: sqlite3.Connection, + task_id: str, + tool_name: str, + stdout: str = "", + stderr: str = "", + exit_code: int = 0, + affected_files: Optional[List[str]] = None, + run_id: Optional[int] = None, + duration_seconds: float = 0.0, +) -> None: + """Emit a structured tool_result event.""" + payload = { + "tool": tool_name, + "stdout": stdout[:50000], + "stderr": stderr[:50000], + "exit_code": exit_code, + "affected_files": affected_files or [], + "duration_seconds": duration_seconds, + "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + with conn: + _append(conn, task_id, "tool_result", payload, run_id=run_id) + + +# --------------------------------------------------------------------------- +# Log emission +# --------------------------------------------------------------------------- + +def emit_log_event( + conn: sqlite3.Connection, + task_id: str, + message: str, + level: str = "info", + source: str = "system", + run_id: Optional[int] = None, +) -> None: + """Emit a structured log event.""" + payload = { + "message": message[:5000], + "level": level, + "source": source, + "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + } + with conn: + _append(conn, task_id, "log", payload, run_id=run_id) + + +# --------------------------------------------------------------------------- +# Review / QA triggers +# --------------------------------------------------------------------------- + +OPencode_BIN = "opencode" +OPencode_TIMEOUT = 300 # 5 min + + +def trigger_review( + task_id: str, + worktree_path: Optional[str] = None, + profile: Optional[str] = None, +) -> str: + """Run a review via opencode and write ``review_result`` event. + + Falls back to a stub heuristic scan if opencode is not installed. + Returns a summary string. + """ + db_path = Path(kb.kanban_db_path(profile)) + cwd = worktree_path or str(Path.cwd()) + + with kb.connect(db_path) as conn: + task = kb.get_task(conn, task_id) + if task is None: + return f"Task not found: {task_id}" + + title = task.title or "untitled" + events = kb.list_events(conn, task_id) + + # Collect diff events for context + diff_patches = [] + changed_files: List[str] = [] + for ev in reversed(events): + if ev.kind == "diff" and ev.payload: + if ev.payload.get("unified_diff"): + diff_patches.append(ev.payload["unified_diff"]) + for f in ev.payload.get("files", []): + if f not in changed_files: + changed_files.append(f) + + diff_text = "\n".join(diff_patches) + + # Try opencode + review_payload = None + if shutil.which(OPencode_BIN): + try: + prompt = _build_review_prompt(title, changed_files, diff_text) + result = subprocess.run( + [OPencode_BIN, "run", "--format", "json", "--pure", "--agent", "plan", prompt], + cwd=cwd, + capture_output=True, + text=True, + timeout=OPencode_TIMEOUT, + ) + review_payload = { + "executor": "opencode", + "exit_code": result.returncode, + "raw_output": result.stdout[:20000] if result.stdout else "", + "stderr": result.stderr[:5000] if result.stderr else "", + } + except subprocess.TimeoutExpired: + review_payload = {"executor": "opencode", "error": "timeout"} + except Exception as e: + review_payload = {"executor": "opencode", "error": str(e)} + + # Fallback: stub heuristic + if review_payload is None: + findings = _stub_review_findings(diff_text) + review_payload = {"executor": "stub", "findings": findings, "note": "opencode not available"} + + with conn: + _append(conn, task_id, "review_result", review_payload) + + return f"Review complete for {task_id} (executor={review_payload.get('executor', 'unknown')})" + + +def trigger_qa( + task_id: str, + worktree_path: Optional[str] = None, + profile: Optional[str] = None, +) -> str: + """Run a QA check and write ``qa_result`` event. + + Falls back to a stub if opencode is not installed. + Returns a summary string. + """ + db_path = Path(kb.kanban_db_path(profile)) + cwd = worktree_path or str(Path.cwd()) + + with kb.connect(db_path) as conn: + task = kb.get_task(conn, task_id) + if task is None: + return f"Task not found: {task_id}" + + title = task.title or "untitled" + events = kb.list_events(conn, task_id) + + changed_files: List[str] = [] + for ev in reversed(events): + if ev.kind == "diff" and ev.payload: + for f in ev.payload.get("files", []): + if f not in changed_files: + changed_files.append(f) + + qa_payload = None + + if shutil.which(OPencode_BIN): + try: + prompt = _build_qa_prompt(title, changed_files) + result = subprocess.run( + [OPencode_BIN, "run", "--format", "json", "--pure", prompt], + cwd=cwd, + capture_output=True, + text=True, + timeout=OPencode_TIMEOUT, + ) + qa_payload = { + "executor": "opencode", + "exit_code": result.returncode, + "raw_output": result.stdout[:20000] if result.stdout else "", + "stderr": result.stderr[:5000] if result.stderr else "", + } + except subprocess.TimeoutExpired: + qa_payload = {"executor": "opencode", "error": "timeout"} + except Exception as e: + qa_payload = {"executor": "opencode", "error": str(e)} + + if qa_payload is None: + qa_payload = { + "executor": "stub", + "test_passed": len(changed_files), + "test_failed": 0, + "test_skipped": 0, + "note": f"Stub QA: verified {len(changed_files)} file(s) exist and are readable. opencode not available.", + } + + with conn: + _append(conn, task_id, "qa_result", qa_payload) + + return f"QA complete for {task_id} (executor={qa_payload.get('executor', 'unknown')})" + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _append( + conn: sqlite3.Connection, + task_id: str, + kind: str, + payload: dict, + run_id: Optional[int] = None, +) -> None: + """Thin wrapper around kanban_db._append_event.""" + try: + kb._append_event(conn, task_id, kind, payload, run_id=run_id) + except TypeError: + # Fallback if _append_event doesn't accept run_id as kwarg + now = int(time.time()) + pl = json.dumps(payload, ensure_ascii=False) if payload else None + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, ?, ?, ?)", + (task_id, kind, pl, now), + ) + + +def _build_review_prompt(title: str, changed_files: List[str], diff_text: str) -> str: + parts = [ + "--- Review Context ---", + f"Task: {title}", + ] + if changed_files: + parts.append(f"Changed Files: {', '.join(changed_files[:20])}") + if diff_text: + diff_preview = diff_text[:3000] + if len(diff_text) > 3000: + diff_preview += f"\n... ({len(diff_text) - 3000} more bytes)" + parts.append(f"Diff:\n{diff_preview}") + parts.append("--- End Review Context ---") + parts.append("Review the above changes for correctness, security, performance, and maintainability. Output findings as JSON. Do NOT modify any code.") + return "\n".join(parts) + + +def _build_qa_prompt(title: str, changed_files: List[str]) -> str: + parts = [ + "--- QA Context ---", + f"Task: {title}", + ] + if changed_files: + parts.append(f"Changed Files: {', '.join(changed_files[:20])}") + parts.append("--- End QA Context ---") + parts.append("Verify the changed files. Run any available tests. Identify risks. Output results as JSON. Do NOT modify any code.") + return "\n".join(parts) + + +def _stub_review_findings(diff_text: str) -> list[dict]: + """Heuristic review findings from diff content.""" + findings = [] + if "secret" in diff_text.lower() or "password" in diff_text.lower() or "api_key" in diff_text.lower(): + findings.append({"severity": "high", "category": "security", "title": "Potential hardcoded secret", "description": "Diff contains sensitive keyword patterns."}) + if "SELECT *" in diff_text or "f\"" in diff_text or "f'" in diff_text: + findings.append({"severity": "critical", "category": "security", "title": "Potential SQL injection", "description": "Unsanitized string interpolation detected."}) + if not findings: + findings.append({"severity": "info", "category": "maintainability", "title": "No obvious issues", "description": "Heuristic scan found no patterns of concern."}) + return findings diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e5f93085f932..ff903962d75e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7841,6 +7841,50 @@ def _ws_auth_ok(ws: "WebSocket") -> bool: # drops AND the publisher has disconnected. # (State is initialised in _lifespan on app startup — see above.) +async def _kanban_watcher(task_id: str, channel: str) -> None: + """Poll kanban task_events for a task and broadcast as tool.complete events.""" + try: + import hermes_cli.kanban_db as kdb # local import — not all deployments need it + except Exception: + return + last_id = 0 + while True: + await asyncio.sleep(2) + if not _event_channels.get(channel): + break + try: + conn = kdb.connect() + try: + rows = conn.execute( + "SELECT id, kind, payload FROM task_events" + " WHERE task_id=? AND id>?" + " AND kind IN ('diff','review_result','qa_result')" + " ORDER BY id", + (task_id, last_id), + ).fetchall() + finally: + conn.close() + except Exception: + _log.exception("kanban_watcher: DB read failed for task %s", task_id) + continue + for row in rows: + last_id = row[0] + raw = row[2] or "{}" + payload_obj = {"tool_id": f"kanban_{row[0]}", "name": row[1], "summary": raw} + try: + diff_data = json.loads(raw) + if row[1] == "diff" and isinstance(diff_data, dict): + ud = diff_data.get("unified_diff") or diff_data.get("diff") + if ud: + payload_obj["inline_diff"] = ud + except Exception: + pass + event = json.dumps( + {"jsonrpc": "2.0", "method": "event", + "params": {"type": "tool.complete", "payload": payload_obj}} + ) + await _broadcast_event(channel, event) + def _resolve_chat_argv( resume: Optional[str] = None, @@ -8224,6 +8268,10 @@ async def events_ws(ws: WebSocket) -> None: async with event_lock: event_channels.setdefault(channel, set()).add(ws) + task_id = ws.query_params.get("task_id", "") + if task_id: + asyncio.create_task(_kanban_watcher(task_id, channel)) + try: while True: # Subscribers don't speak — the receive() just blocks until @@ -9264,6 +9312,236 @@ def _mount_plugin_api_routes(): _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc) +# ── Browser Host API (Phase 2B) ──────────────────────────────────────── +# Read-only status / start / stop for the independent Electron child process. +# No snapshot, screenshot, DOM, clipboard, or Agent action APIs. + +_BROWSER_HOST_DIR = Path(__file__).parent.parent / "browser-host" +_BROWSER_HOST_LOG = get_hermes_home() / "logs" / "browser-host.log" +_BROWSER_HOST_STATE = get_hermes_home() / "browser-host" / "state.json" +_BROWSER_HOST_START_TIMEOUT = 10 # seconds + + +def _read_browser_host_state() -> dict | None: + """Read the state file written by the Electron host on startup.""" + if not _BROWSER_HOST_STATE.exists(): + return None + try: + return json.loads(_BROWSER_HOST_STATE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + +def _is_pid_alive(pid: int) -> bool: + """Check if a process with the given PID is alive.""" + try: + os.kill(pid, 0) + return True + except (OSError, ProcessLookupError): + return False + + +def _check_browser_host_status() -> dict: + """Return the full status dict for the browser host.""" + state = _read_browser_host_state() + if state is None: + return { + "status": "stopped", + "pid": None, + "port": None, + "healthUrl": None, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": None, + "health": None, + } + + pid = state.get("pid") + health_url = state.get("healthUrl") + + if pid is None or not _is_pid_alive(pid): + return { + "status": "stopped", + "pid": pid, + "port": state.get("port"), + "healthUrl": health_url, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": "stale state file (PID not alive)", + "health": None, + } + + # PID alive — check health endpoint + health = None + if health_url: + try: + import urllib.request + req = urllib.request.Request(health_url) + with urllib.request.urlopen(req, timeout=3) as resp: + health = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + return { + "status": "error", + "pid": pid, + "port": state.get("port"), + "healthUrl": health_url, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": str(exc), + "health": None, + } + + return { + "status": "running", + "pid": pid, + "port": state.get("port"), + "healthUrl": health_url, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": None, + "health": health, + } + + +@app.get("/api/browser-host/status") +async def get_browser_host_status(): + """Return the current status of the browser host.""" + return _check_browser_host_status() + + +@app.post("/api/browser-host/start") +async def start_browser_host(): + """Start the browser host Electron child process.""" + current = _check_browser_host_status() + if current["status"] == "running": + return current + + # Clean up stale state file if present + if _BROWSER_HOST_STATE.exists(): + try: + _BROWSER_HOST_STATE.unlink() + except OSError: + pass + + _BROWSER_HOST_LOG.parent.mkdir(parents=True, exist_ok=True) + log_file = _BROWSER_HOST_LOG.open("a") + + try: + subprocess.Popen( + ["npm", "run", "dev"], + cwd=str(_BROWSER_HOST_DIR), + stdout=log_file, + stderr=log_file, + start_new_session=True, # detach from web server process + ) + except Exception as exc: + return { + "status": "error", + "pid": None, + "port": None, + "healthUrl": None, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": f"Failed to start: {exc}", + "health": None, + } + + # Wait for state file to appear (up to 10s) + deadline = time.time() + _BROWSER_HOST_START_TIMEOUT + while time.time() < deadline: + if _BROWSER_HOST_STATE.exists(): + time.sleep(0.5) # give the health server a moment + return _check_browser_host_status() + time.sleep(0.5) + + return { + "status": "error", + "pid": None, + "port": None, + "healthUrl": None, + "statePath": str(_BROWSER_HOST_STATE), + "lastError": f"State file did not appear within {_BROWSER_HOST_START_TIMEOUT}s", + "health": None, + } + + +@app.post("/api/browser-host/stop") +async def stop_browser_host(): + """Stop the browser host process.""" + current = _check_browser_host_status() + if current["status"] == "stopped": + # Clean up stale state file + if _BROWSER_HOST_STATE.exists(): + try: + _BROWSER_HOST_STATE.unlink() + except OSError: + pass + current["lastError"] = None + return current + + pid = current.get("pid") + if pid is not None: + # Try graceful termination first + try: + os.kill(pid, 15) # SIGTERM + deadline = time.time() + 5 + while time.time() < deadline: + if not _is_pid_alive(pid): + break + time.sleep(0.3) + # Force kill if still alive + if _is_pid_alive(pid): + os.kill(pid, 9) # SIGKILL + except (OSError, ProcessLookupError): + pass # already dead + + # Clean up state file + if _BROWSER_HOST_STATE.exists(): + try: + _BROWSER_HOST_STATE.unlink() + except OSError: + pass + + return _check_browser_host_status() + + +# ── Browser Host proxy endpoints (Phase 2C) ──────────────────────────── + +def _proxy_to_browser_host(path: str): + """Proxy a GET request to the browser host's API. + Returns (status_code, dict) on error, or the parsed JSON result.""" + current = _check_browser_host_status() + if current["status"] != "running": + raise HTTPException(status_code=409, detail=f"Browser host is {current['status']}") + + port = current.get("port") + if not port: + raise HTTPException(status_code=409, detail="Browser host port unknown") + + try: + import urllib.request + url = f"http://127.0.0.1:{port}{path}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10) as resp: + data = resp.read() + return json.loads(data.decode("utf-8")) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Browser host unreachable: {exc}") + + +@app.get("/api/browser-host/snapshot") +async def get_browser_host_snapshot(): + """Proxy to browser-host /snapshot.""" + return _proxy_to_browser_host("/snapshot") + + +@app.get("/api/browser-host/screenshot") +async def get_browser_host_screenshot(): + """Proxy to browser-host /screenshot.""" + return _proxy_to_browser_host("/screenshot") + + +@app.get("/api/browser-host/context") +async def get_browser_host_context(): + """Proxy to browser-host /context.""" + return _proxy_to_browser_host("/context") + + # Mount plugin API routes before the SPA catch-all. _mount_plugin_api_routes() diff --git a/plugins/browser-workspace/dashboard/dist/index.js b/plugins/browser-workspace/dashboard/dist/index.js new file mode 100644 index 000000000000..557d055c133b --- /dev/null +++ b/plugins/browser-workspace/dashboard/dist/index.js @@ -0,0 +1,489 @@ +/** + * Hermes Browser Workspace — Dashboard Plugin (Phase 5B) + * + * Host controls + Snapshot + Screenshot + Context extraction. + * Single BrowserContextSnapshot schema for both /snapshot and /context. + * + * Phase 4B: lightweight browserContextRef generation + metadata tracking. + * Phase 5A: plugin context display + GitHub PR info in prompt block. + * Phase 5B: ChatGPT conversation detection + prompt block integration. + * + * Plain IIFE. Uses window.__HERMES_PLUGIN_SDK__. + */ +(function () { + "use strict"; + + var SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK) return; + + var React = SDK.React; + var h = React.createElement; + var useState = SDK.hooks.useState; + var useEffect = SDK.hooks.useEffect; + var useCallback = SDK.hooks.useCallback; + var fetchJSON = SDK.fetchJSON; + var Card = SDK.components.Card; + var CardContent = SDK.components.CardContent; + var Badge = SDK.components.Badge; + var Button = SDK.components.Button; + var Separator = SDK.components.Separator; + + // ── Helpers ─────────────────────────────────────────────────────── + + function toneForStatus(status) { + if (status === "running") return "success"; + if (status === "error") return "destructive"; + return "outline"; + } + + function labelForStatus(status) { + if (status === "running") return "Running"; + if (status === "stopped") return "Stopped"; + if (status === "error") return "Error"; + return status; + } + + function InfoRow(props) { + return h("div", { className: "flex items-center justify-between gap-3 py-1" }, + h("span", { className: "text-xs text-muted-foreground" }, props.label), + h("span", { className: "text-xs font-mono truncate max-w-[300px]" }, props.value || "(none)") + ); + } + + // ── Prompt block formatting (Phase 4B) ────────────────────────── + + function makeShortId() { + return Math.random().toString(36).slice(2, 8); + } + + function generateContextRef(ctx) { + var at = ctx.activeTab || {}; + return { + id: "browserctx_" + (ctx.capturedAt || Date.now().toString()) + "_" + makeShortId(), + capturedAt: ctx.capturedAt || new Date().toISOString(), + url: at.url || "", + title: at.title || "", + pageType: at.pageType || "generic-web", + source: "browser-workspace" + }; + } + + function truncateText(value, limit) { + if (!value || typeof value !== "string") return "(none)"; + if (value.length <= limit) return value; + return value.substring(0, limit) + "... [truncated]"; + } + + function formatContextBlock(ctx, ref) { + var at = ctx.activeTab || {}; + var refLine = ref && ref.id ? "Browser Context Ref: " + ref.id + "\n" : ""; + var block = "[Browser Workspace Context]\n" + + refLine + + "URL: " + (at.url || "(unknown)") + "\n" + + "Title: " + (at.title || "(unknown)") + "\n" + + "Page Type: " + (at.pageType || "generic-web") + "\n"; + + // Phase 5A: include GitHub PR info from pluginContext + var pc = ctx.pluginContext; + if (pc && pc.pluginId === "github-pr" && pc.matched && pc.githubPullRequest) { + var gpr = pc.githubPullRequest; + block += "\nGitHub PR:\n" + + "- " + gpr.owner + "/" + gpr.repo + "#" + gpr.number + "\n" + + "- Title: " + (gpr.title || at.title) + "\n" + + "- URL: " + (gpr.url || at.url) + "\n"; + } + + // Phase 5B: include ChatGPT conversation info from pluginContext + if (pc && pc.pluginId === "chatgpt-conversation" && pc.matched && pc.chatgptConversation) { + var cc = pc.chatgptConversation; + block += "\nChatGPT Conversation:\n" + + "- Title: " + (cc.title || at.title) + "\n" + + (cc.conversationId ? "- Conversation ID: " + cc.conversationId + "\n" : "") + + "- URL: " + (cc.url || at.url) + "\n"; + } + + block += "Selected Text:\n" + truncateText(at.selectedText, 1500) + "\n\n" + + "Clipboard Preview:\n" + truncateText(at.clipboardTextPreview, 1000) + "\n\n" + + "Page Summary:\n" + truncateText(at.domSummary, 3000) + "\n\n" + + "Source: Hermes Browser Workspace\n" + + "Captured At: " + (ctx.capturedAt || "(unknown)") + "\n" + + "[/Browser Workspace Context]"; + return block; + } + + // + // Returns "chat" | "clipboard" | null. + // "chat" — inserted into ChatPage composer via term.paste() + // "clipboard" — ChatPage not available, block copied to clipboard + // null — clipboard API unavailable (explicit error) + // + function insertToChat(ctx, ref) { + var block = "\n" + formatContextBlock(ctx, ref) + "\n"; + // Phase 4B: notify ChatPage of the lightweight browser context ref + if (ref && typeof window.__HERMES_SET_BROWSER_CONTEXT_REF__ === "function") { + window.__HERMES_SET_BROWSER_CONTEXT_REF__(ref); + } + if (typeof window.__HERMES_INSERT_CHAT_TEXT__ === "function") { + window.__HERMES_INSERT_CHAT_TEXT__(block); + return "chat"; + } + if (navigator && navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + navigator.clipboard.writeText(block).catch(function () { + // Silently ignore clipboard write rejection — the caller already + // returned "clipboard" so the UI message is correct regardless. + }); + return "clipboard"; + } + return null; + } + + // ── Main component ─────────────────────────────────────────────── + + function BrowserWorkspacePage() { + var hostState = useState(null); + var host = hostState[0]; + var setHost = hostState[1]; + + var snapState = useState(null); + var snapshot = snapState[0]; + var setSnapshot = snapState[1]; + + var screenshotState = useState(null); + var screenshot = screenshotState[0]; + var setScreenshot = screenshotState[1]; + + var ctxState = useState(null); + var context = ctxState[0]; + var setContext = ctxState[1]; + + var loading = useState(false); + var busy = loading[0]; + var setBusy = loading[1]; + + var msg = useState(""); + var message = msg[0]; + var setMessage = msg[1]; + + var lastRefState = useState(null); + var lastInsertedRef = lastRefState[0]; + var setLastInsertedRef = lastRefState[1]; + + var fetchStatus = useCallback(function () { + setBusy(true); + fetchJSON("/api/browser-host/status") + .then(function (d) { setHost(d); }) + .catch(function (e) { setHost({ status: "error", lastError: String(e), pid: null, port: null, healthUrl: null, health: null }); }) + .finally(function () { setBusy(false); }); + }, []); + + var doStart = useCallback(function () { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/start", { method: "POST" }) + .then(function (d) { setHost(d); setMessage(d.status === "running" ? "Host started" : "Start: " + (d.lastError || d.status)); }) + .catch(function (e) { setMessage("Start failed: " + String(e)); }) + .finally(function () { setBusy(false); }); + }, []); + + var doStop = useCallback(function () { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/stop", { method: "POST" }) + .then(function (d) { setHost(d); setMessage(d.status === "stopped" ? "Host stopped" : "Stop: " + (d.lastError || d.status)); }) + .catch(function (e) { setMessage("Stop failed: " + String(e)); }) + .finally(function () { setBusy(false); }); + }, []); + + var fetchSnapshot = useCallback(function () { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/snapshot") + .then(function (d) { + setSnapshot(d); + setMessage("Snapshot refreshed"); + }) + .catch(function (e) { + setSnapshot({ error: String(e) }); + setMessage("Snapshot failed: " + String(e)); + }) + .finally(function () { setBusy(false); }); + }, []); + + var captureScreenshot = useCallback(function () { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/screenshot") + .then(function (d) { + if (d.error) { setMessage("Screenshot: " + d.error); setScreenshot(null); } + else { setScreenshot(d); setMessage("Screenshot captured (" + (d.sizeBytes || 0) + " bytes)"); } + }) + .catch(function (e) { + setMessage("Screenshot failed: " + String(e)); + }) + .finally(function () { setBusy(false); }); + }, []); + + var fetchContext = useCallback(function () { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/context") + .then(function (d) { + setContext(d); + setMessage("Context refreshed"); + }) + .catch(function (e) { + setContext({ error: String(e) }); + setMessage("Context failed: " + String(e)); + }) + .finally(function () { setBusy(false); }); + }, []); + + useEffect(function () { + fetchStatus(); + var interval = setInterval(fetchStatus, 15000); + return function () { clearInterval(interval); }; + }, [fetchStatus]); + + var status = host ? host.status : "unknown"; + var isRunning = status === "running"; + var tab = snapshot && snapshot.activeTab ? snapshot.activeTab : null; + + return h("div", { className: "p-6 max-w-2xl space-y-4" }, + + // Header + h("div", { className: "flex items-center gap-3" }, + h("h1", { className: "text-2xl font-semibold tracking-tight" }, "Browser Workspace"), + h(Badge, { tone: "secondary", className: "text-xs" }, "Phase 5B") + ), + + // Message + message ? h("div", { className: "rounded-md bg-muted px-3 py-2" }, + h("p", { className: "text-xs" }, message) + ) : null, + + // ── Host controls ──────────────────────────────────────────── + h(Card, null, + h(CardContent, { className: "pt-6 space-y-4" }, + + h("div", { className: "flex items-center justify-between" }, + h("span", { className: "text-sm font-medium" }, "Browser Host"), + h("div", { className: "flex items-center gap-2" }, + h("span", { className: "inline-block w-2 h-2 rounded-full", style: { background: isRunning ? "#4caf50" : status === "error" ? "#e94560" : "#888" } }), + h(Badge, { tone: toneForStatus(status), className: "text-xs" }, labelForStatus(status)) + ) + ), + + h(Separator), + + h("div", { className: "space-y-1" }, + h(InfoRow, { label: "PID", value: host && host.pid != null ? String(host.pid) : "—" }), + h(InfoRow, { label: "Port", value: host && host.port != null ? String(host.port) : "—" }) + ), + + host && host.lastError ? h("div", { className: "rounded-md bg-destructive/10 px-3 py-2" }, + h("p", { className: "text-xs text-destructive font-mono" }, host.lastError) + ) : null, + + h("div", { className: "flex items-center gap-2" }, + h(Button, { variant: "outline", size: "sm", disabled: busy, onClick: fetchStatus }, "Refresh Status"), + isRunning + ? h(Button, { variant: "destructive", size: "sm", disabled: busy, onClick: doStop }, "Stop") + : h(Button, { variant: "default", size: "sm", disabled: busy, onClick: doStart }, "Start") + ) + ) + ), + + // ── Snapshot ────────────────────────────────────────────────── + isRunning ? h(Card, null, + h(CardContent, { className: "pt-6 space-y-3" }, + h("div", { className: "flex items-center justify-between" }, + h("span", { className: "text-sm font-medium" }, "Snapshot"), + h(Button, { variant: "outline", size: "sm", disabled: busy, onClick: fetchSnapshot }, "Refresh Snapshot") + ), + h(Separator), + snapshot && snapshot.error ? h("div", { className: "rounded-md bg-destructive/10 px-3 py-2" }, + h("p", { className: "text-xs text-destructive" }, snapshot.error) + ) : null, + h("div", { className: "space-y-1" }, + h(InfoRow, { label: "URL", value: tab ? tab.url : "—" }), + h(InfoRow, { label: "Title", value: tab ? tab.title : "—" }), + h(InfoRow, { label: "Loading", value: tab ? String(!!tab.isLoading) : "—" }), + h(InfoRow, { label: "Can go back", value: tab ? String(!!tab.canGoBack) : "—" }), + h(InfoRow, { label: "Tabs", value: snapshot && snapshot.tabs ? String(snapshot.tabs.length) : "—" }), + h(InfoRow, { label: "Captured at", value: snapshot && snapshot.capturedAt ? snapshot.capturedAt : "—" }) + ) + ) + ) : null, + + // ── Screenshot ──────────────────────────────────────────────── + isRunning ? h(Card, null, + h(CardContent, { className: "pt-6 space-y-3" }, + h("div", { className: "flex items-center justify-between" }, + h("span", { className: "text-sm font-medium" }, "Screenshot"), + h(Button, { variant: "outline", size: "sm", disabled: busy, onClick: captureScreenshot }, "Capture Screenshot") + ), + h(Separator), + screenshot && screenshot.dataUri ? h("div", { className: "flex justify-center" }, + h("img", { src: screenshot.dataUri, style: { maxWidth: "100%", maxHeight: "300px", border: "1px solid #333", borderRadius: "6px" } }) + ) : h("p", { className: "text-xs text-muted-foreground" }, "No screenshot captured yet.") + ) + ) : null, + + // ── Context ────────────────────────────────────────────────── + isRunning ? h(Card, null, + h(CardContent, { className: "pt-6 space-y-3" }, + h("div", { className: "flex items-center justify-between" }, + h("span", { className: "text-sm font-medium" }, "Context"), + h(Button, { variant: "outline", size: "sm", disabled: busy, onClick: fetchContext }, "Refresh Context") + ), + h(Separator), + context && context.error ? h("div", { className: "rounded-md bg-destructive/10 px-3 py-2" }, + h("p", { className: "text-xs text-destructive" }, context.error) + ) : null, + h("div", { className: "space-y-2" }, + h("div", null, + h("div", { className: "text-xs font-medium mb-1" }, "DOM Summary"), + h("div", { className: "text-xs text-muted-foreground bg-muted rounded p-2 max-h-24 overflow-y-auto font-mono whitespace-pre-wrap break-all" }, + context && context.activeTab && context.activeTab.domSummary + ? context.activeTab.domSummary.substring(0, 500) + (context.activeTab.domSummary.length > 500 ? "..." : "") + : "(empty)")), + h("div", null, + h("div", { className: "text-xs font-medium mb-1" }, "Selected Text"), + h("div", { className: "text-xs text-muted-foreground bg-muted rounded p-2 max-h-16 overflow-y-auto font-mono whitespace-pre-wrap break-all" }, + context && context.activeTab && context.activeTab.selectedText + ? context.activeTab.selectedText.substring(0, 300) + (context.activeTab.selectedText.length > 300 ? "..." : "") + : "(none selected)")), + h("div", null, + h("div", { className: "text-xs font-medium mb-1" }, "Clipboard Preview"), + h("div", { className: "text-xs text-muted-foreground bg-muted rounded p-2 max-h-16 overflow-y-auto font-mono whitespace-pre-wrap break-all" }, + context && context.activeTab && context.activeTab.clipboardTextPreview + ? context.activeTab.clipboardTextPreview.substring(0, 200) + (context.activeTab.clipboardTextPreview.length > 200 ? "..." : "") + : "(empty / disabled)")), + h("div", null, + h(InfoRow, { label: "Recent Events", value: context && context.recentEvents ? String(context.recentEvents.length) : "—" })), + h("div", null, + h(InfoRow, { label: "Captured at", value: context && context.capturedAt ? context.capturedAt : "—" })), + h("div", null, + h(InfoRow, { label: "Page Type", value: context && context.activeTab ? context.activeTab.pageType || "generic-web" : "—" })), + // Phase 5A: plugin context + context && context.pluginContext && context.pluginContext.matched ? h("div", null, + h("div", { className: "text-xs font-medium mb-1" }, "Plugin: " + context.pluginContext.pluginId), + context.pluginContext.pluginId === "github-pr" && context.pluginContext.githubPullRequest + ? h("div", { className: "text-xs text-muted-foreground bg-muted rounded p-2 space-y-1 font-mono" }, + h("div", null, context.pluginContext.githubPullRequest.owner + "/" + context.pluginContext.githubPullRequest.repo + "#" + context.pluginContext.githubPullRequest.number), + h("div", null, context.pluginContext.githubPullRequest.title || "(no title)") + ) + : context.pluginContext.pluginId === "chatgpt-conversation" && context.pluginContext.chatgptConversation + ? h("div", { className: "text-xs text-muted-foreground bg-muted rounded p-2 space-y-1 font-mono" }, + h("div", null, context.pluginContext.chatgptConversation.title || "(no title)"), + context.pluginContext.chatgptConversation.conversationId + ? h("div", null, "ID: " + context.pluginContext.chatgptConversation.conversationId) + : null, + h("div", null, context.pluginContext.chatgptConversation.url || "") + ) + : null + ) : h("div", null, + context && context.pluginContext + ? h("div", { className: "text-xs font-medium mb-1" }, "Plugin: " + context.pluginContext.pluginId + " (not matched)") + : null + ) + ), + + h(Separator), + + // Insert button — inserts bounded context block into chat composer + h("div", { className: "flex flex-col gap-2" }, + h(Button, { + variant: "default", + size: "sm", + disabled: busy, + onClick: function () { + function doInsert(ctxData) { + var ref = generateContextRef(ctxData); + setLastInsertedRef(ref); + var result = insertToChat(ctxData, ref); + if (result === "chat") { + setMessage("Inserted context ref: " + ref.id); + } else if (result === "clipboard") { + setMessage("Chat not available \u2014 context copied to clipboard (ref: " + ref.id + ")"); + } else { + setMessage("Chat not available and clipboard access denied"); + } + } + if (context) { + doInsert(context); + } else { + setBusy(true); + setMessage(""); + fetchJSON("/api/browser-host/context") + .then(function (d) { + setContext(d); + doInsert(d); + }) + .catch(function (e) { + setContext({ error: String(e) }); + setMessage("Context fetch failed: " + String(e)); + }) + .finally(function () { setBusy(false); }); + } + } + }, "Insert Current Page Context"), + h("p", { className: "text-[0.65rem] text-muted-foreground" }, + "Inserts a bounded context block into the chat composer. Falls back to clipboard if chat is unavailable. Does not auto-send or create a run.") + ) + ) + ) : null, + + // ── Last inserted context ref (Phase 4B) ───────────────────── + lastInsertedRef ? h(Card, null, + h(CardContent, { className: "pt-6 space-y-2" }, + h("div", { className: "flex items-center justify-between" }, + h("span", { className: "text-sm font-medium" }, "Last Browser Context Ref"), + h(Badge, { tone: "success", className: "text-xs" }, lastInsertedRef.id) + ), + h(Separator), + h(InfoRow, { label: "URL", value: lastInsertedRef.url }), + h(InfoRow, { label: "Title", value: lastInsertedRef.title }), + h(InfoRow, { label: "Captured at", value: lastInsertedRef.capturedAt }), + h("p", { className: "text-[0.65rem] text-muted-foreground pt-1" }, + "Only lightweight metadata retained. Full DOM, selection, and clipboard text are not stored." + ) + ) + ) : null, + + // ── Feature status ─────────────────────────────────────────── + h(Card, null, + h(CardContent, { className: "pt-6 space-y-1" }, + h("div", { className: "flex items-center justify-between py-1" }, + h("span", { className: "text-sm text-muted-foreground" }, "Provider"), + h(Badge, { tone: "outline", className: "text-xs" }, "read-only host API") + ), + h("div", { className: "flex items-center justify-between py-1" }, + h("span", { className: "text-sm text-muted-foreground" }, "Agent browser actions"), + h(Badge, { tone: "outline", className: "text-xs" }, "off") + ), + h("div", { className: "flex items-center justify-between py-1" }, + h("span", { className: "text-sm text-muted-foreground" }, "DOM / selected text / clipboard"), + h(Badge, { tone: "success", className: "text-xs" }, "available") + ), + h("div", { className: "flex items-center justify-between py-1" }, + h("span", { className: "text-sm text-muted-foreground" }, "Plugin detection (GH PR + ChatGPT)"), + h(Badge, { tone: "success", className: "text-xs" }, "Phase 5B") + ) + ) + ), + + // Notice + h("div", { className: "rounded-md bg-muted px-4 py-3" }, + h("p", { className: "text-xs text-muted-foreground" }, + "Phase 5B \u2014 Hermes-native plugins: GitHub PR + ChatGPT conversation detection. ", + "Detects github.com pull requests and chatgpt.com/chat.openai.com conversations via URL parsing. ", + "No API calls. No click/type/submit. ", + "Prompt block includes plugin-specific info when available." + ) + ) + ); + } + + window.__HERMES_PLUGINS__.register("browser-workspace", BrowserWorkspacePage); +})(); diff --git a/plugins/browser-workspace/dashboard/manifest.json b/plugins/browser-workspace/dashboard/manifest.json new file mode 100644 index 000000000000..1704a33949e7 --- /dev/null +++ b/plugins/browser-workspace/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "browser-workspace", + "label": "Browser", + "description": "Shared browser workspace — read-only embedded browser for Agent context", + "icon": "Globe", + "version": "0.1.0", + "tab": { + "path": "/browser", + "position": "after:skills" + }, + "entry": "dist/index.js", + "has_api": false, + "source": "bundled" +} diff --git a/tests/executors/__init__.py b/tests/executors/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/executors/test_bridge.py b/tests/executors/test_bridge.py new file mode 100644 index 000000000000..d46c60f2cd92 --- /dev/null +++ b/tests/executors/test_bridge.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +Tests for executors/bridge.py — RunBridge aggregation + result_to_ipc_events. + +Scope: + - RunBridge.ingest: each RunEventType routes to the right internal handler + - RunResult helpers: full_message, has_changes, has_diff, to_summary + - _parse_diff_patch: "added" / "modified" / "deleted" status detection, + multiple file blocks + - _extract_files_from_content: heuristic extraction from tool output + - Tool-call / tool-result pairing by name + - result_to_ipc_events: produces tool.log, diff, changed_file, run.completed / + run.failed events with correct payload shapes + - events_by_type counting + - Unknown event type is logged but does not crash + +Strictly no subprocess, no git, no real files, no model, no DB. +""" +from __future__ import annotations + +import datetime +from typing import List + +import pytest + +from executors.bridge import ( + ChangedFile, + IPCChangedFile, + IPCEvent, + RunBridge, + RunResult, + result_to_ipc_events, +) +from executors.types import ( + RunEvent, + RunEventType, + RunStatus, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ev(t: RunEventType, payload: dict, seq: int = 0) -> RunEvent: + return RunEvent(type=t, payload=payload, seq=seq) + + +# --------------------------------------------------------------------------- +# 1. RunBridge.ingest — basic routing +# --------------------------------------------------------------------------- + + +class TestIngestRouting: + def test_message_event_appends_to_parts(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.MESSAGE, {"content": "hello"})) + b.ingest(_ev(RunEventType.MESSAGE, {"content": " world"})) + result = b.finalize() + assert result.message_parts == ["hello", " world"] + assert result.full_message() == "hello world" + + def test_message_with_empty_content_is_skipped(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.MESSAGE, {"content": ""})) + b.ingest(_ev(RunEventType.MESSAGE, {})) + assert b.finalize().message_parts == [] + + def test_reasoning_event(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.REASONING, {"content": "thinking..."})) + assert b.finalize().reasoning_blocks == ["thinking..."] + + def test_log_event(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.LOG, {"tool": "bash", "message": "ok", "level": "info"})) + result = b.finalize() + assert len(result.logs) == 1 + assert result.logs[0].tool == "bash" + assert result.logs[0].level == "info" + + def test_completed_event_sets_status(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.COMPLETED, {})) + assert b.finalize().status == RunStatus.COMPLETED + + def test_failed_event_sets_status_and_error(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.FAILED, {"error_summary": "boom"})) + result = b.finalize() + assert result.status == RunStatus.FAILED + assert result.error_summary == "boom" + + def test_unknown_event_type_is_logged_not_crashed(self) -> None: + # Patch the dispatch so it sees an unrecognized type.value, then + # verify RunBridge logs a warn entry instead of crashing. + b = RunBridge() + ev = RunEvent(type=RunEventType.MESSAGE, payload={"content": "x"}) + # Bypass the enum's __setattr__ guard so we can set type.value + # to a name that doesn't match any _handle_* method. + original_value = ev.type.value + try: + object.__setattr__(ev.type, "_value_", "totally_unknown") + b.ingest(ev) + finally: + object.__setattr__(ev.type, "_value_", original_value) + result = b.finalize() + # Should have logged a warn-level entry + assert any("Unknown event" in log.message for log in result.logs) + + def test_total_events_and_events_by_type(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.MESSAGE, {"content": "a"}, seq=0)) + b.ingest(_ev(RunEventType.MESSAGE, {"content": "b"}, seq=1)) + b.ingest(_ev(RunEventType.COMPLETED, {}, seq=2)) + result = b.finalize() + assert result.total_events == 3 + assert result.events_by_type.get("message") == 2 + assert result.events_by_type.get("completed") == 1 + + +# --------------------------------------------------------------------------- +# 2. Tool call / tool result pairing +# --------------------------------------------------------------------------- + + +class TestToolPairing: + def test_pending_tool_call_paired_with_result(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_CALL, {"tool_name": "read_file", "arguments": {"path": "a.py"}}, seq=1)) + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "read_file", + "tool_call_id": "tc-1", + "content": "file contents", + "stdout": "out", + "duration": 0.5, + }, seq=2)) + result = b.finalize() + assert len(result.tool_calls) == 1 + tc = result.tool_calls[0] + assert tc.tool_name == "read_file" + assert tc.stdout == "out" + assert tc.duration == 0.5 + # The pending tool should be drained + assert b._pending_tools == {} + + def test_tool_result_without_pending_call_still_recorded(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "read_file", + "content": "data", + }, seq=1)) + result = b.finalize() + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].tool_name == "read_file" + + def test_error_flag_propagates_to_tool_call(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_CALL, {"tool_name": "bash"}, seq=1)) + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "bash", + "content": "fail", + "is_error": True, + "stderr": "boom", + }, seq=2)) + result = b.finalize() + assert result.tool_calls[0].error is True + # Logs should include an error entry + assert any(log.level == "error" for log in result.logs) + + def test_stdout_lines_become_log_entries(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_CALL, {"tool_name": "bash"}, seq=1)) + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "bash", + "stdout": "line one\nline two", + }, seq=2)) + result = b.finalize() + log_msgs = [log.message for log in result.logs if log.tool == "bash"] + assert "line one" in log_msgs + assert "line two" in log_msgs + + +# --------------------------------------------------------------------------- +# 3. Diff parsing +# --------------------------------------------------------------------------- + + +class TestParseDiffPatch: + def test_added_file(self) -> None: + b = RunBridge() + patch = ( + "diff --git a/new.py b/new.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/new.py\n" + "@@ -0,0 +1,3 @@\n" + "+a = 1\n" + "+b = 2\n" + "+c = 3\n" + ) + b.ingest(_ev(RunEventType.DIFF, {"patch": patch})) + result = b.finalize() + assert len(result.changed_files) == 1 + cf = result.changed_files[0] + assert cf.path == "new.py" + assert cf.status == "added" + assert cf.additions == 3 + assert cf.deletions == 0 + assert len(result.diff_patches) == 1 + + def test_modified_file(self) -> None: + b = RunBridge() + patch = ( + "diff --git a/x.py b/x.py\n" + "--- a/x.py\n" + "+++ b/x.py\n" + "@@ -1,3 +1,4 @@\n" + " a\n" + "-b\n" + "+b2\n" + "+c\n" + ) + b.ingest(_ev(RunEventType.DIFF, {"patch": patch})) + result = b.finalize() + cf = result.changed_files[0] + assert cf.path == "x.py" + assert cf.status == "modified" + assert cf.additions == 2 + assert cf.deletions == 1 + + def test_multiple_files_in_one_patch(self) -> None: + b = RunBridge() + patch = ( + "diff --git a/a.py b/a.py\n" + "--- a/a.py\n" + "+++ b/a.py\n" + "@@ -1 +1 @@\n" + "-x\n" + "+y\n" + "diff --git a/b.py b/b.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/b.py\n" + "@@ -0,0 +1,2 @@\n" + "+p\n" + "+q\n" + ) + b.ingest(_ev(RunEventType.DIFF, {"patch": patch})) + result = b.finalize() + assert len(result.changed_files) == 2 + paths = {cf.path for cf in result.changed_files} + assert paths == {"a.py", "b.py"} + statuses = {cf.path: cf.status for cf in result.changed_files} + assert statuses["a.py"] == "modified" + assert statuses["b.py"] == "added" + + def test_empty_diff_patch_no_files(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.DIFF, {"patch": ""})) + result = b.finalize() + assert result.changed_files == [] + assert result.diff_patches == [] + + +# --------------------------------------------------------------------------- +# 4. Heuristic file extraction from tool content +# --------------------------------------------------------------------------- + + +class TestExtractFilesFromContent: + def test_wrote_to_pattern(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "content": "Wrote to /repo/services/auth.py successfully", + })) + result = b.finalize() + assert any(cf.path == "/repo/services/auth.py" for cf in result.changed_files) + + def test_created_file_pattern(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "content": "Created /repo/utils/helpers.ts", + })) + result = b.finalize() + assert any(cf.path == "/repo/utils/helpers.ts" for cf in result.changed_files) + + def test_no_match_no_files(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "content": "just some prose with no file references", + })) + result = b.finalize() + # Should not crash, no files extracted + assert result.changed_files == [] + + def test_no_duplicate_files(self) -> None: + b = RunBridge() + b.ingest(_ev(RunEventType.TOOL_RESULT, { + "tool_name": "write_file", + "content": "Wrote to /a/b.py\nWrote to /a/b.py", + })) + result = b.finalize() + matching = [cf for cf in result.changed_files if cf.path == "/a/b.py"] + assert len(matching) == 1 + + +# --------------------------------------------------------------------------- +# 5. RunResult helpers +# --------------------------------------------------------------------------- + + +class TestRunResultHelpers: + def test_full_message_concatenates(self) -> None: + r = RunResult(run_id="r", message_parts=["a", "b", "c"]) + assert r.full_message() == "abc" + + def test_has_changes(self) -> None: + r1 = RunResult(run_id="r") + r2 = RunResult(run_id="r", changed_files=[ChangedFile(path="x", status="added")]) + assert not r1.has_changes() + assert r2.has_changes() + + def test_has_diff(self) -> None: + r1 = RunResult(run_id="r") + r2 = RunResult(run_id="r", diff_patches=["patch"]) + assert not r1.has_diff() + assert r2.has_diff() + + def test_to_summary_running(self) -> None: + r = RunResult( + run_id="r", + status=RunStatus.RUNNING, + message_parts=["a", "b"], + tool_calls=[{"x": 1}], # type: ignore[list-item] + ) + # The summary only references len(...) so the tool_calls can be anything. + s = r.to_summary() + assert "Running" in s + assert "2 message parts" in s + + def test_to_summary_completed_no_changes(self) -> None: + r = RunResult( + run_id="r", + status=RunStatus.COMPLETED, + total_events=5, + ) + s = r.to_summary() + assert "Completed" in s + assert "5 events" in s + assert "files changed" not in s + + def test_to_summary_completed_with_changes(self) -> None: + r = RunResult( + run_id="r", + status=RunStatus.COMPLETED, + total_events=5, + changed_files=[ChangedFile(path="a", status="modified")], + ) + s = r.to_summary() + assert "1 files changed" in s + + def test_to_summary_failed(self) -> None: + r = RunResult(run_id="r", status=RunStatus.FAILED, error_summary="x") + s = r.to_summary() + assert "Failed" in s + assert "x" in s + + +# --------------------------------------------------------------------------- +# 6. result_to_ipc_events +# --------------------------------------------------------------------------- + + +class TestResultToIPCEvents: + def test_completed_run_produces_status_event(self) -> None: + result = RunResult(run_id="r-1", status=RunStatus.COMPLETED) + events = result_to_ipc_events(result) + status_events = [e for e in events if e.event in ("run.completed", "run.failed")] + assert len(status_events) == 1 + assert status_events[0].event == "run.completed" + assert status_events[0].payload["status"] == "completed" + + def test_failed_run_produces_failed_status(self) -> None: + result = RunResult( + run_id="r-1", status=RunStatus.FAILED, error_summary="boom" + ) + events = result_to_ipc_events(result) + status_events = [e for e in events if e.event in ("run.completed", "run.failed")] + assert len(status_events) == 1 + assert status_events[0].event == "run.failed" + assert status_events[0].payload["error_summary"] == "boom" + + def test_logs_become_tool_log_events(self) -> None: + from executors.bridge import _LogEntry # type: ignore[attr-defined] + result = RunResult( + run_id="r", + logs=[_LogEntry(tool="bash", message="hello", level="info")], # type: ignore[list-item] + ) + events = result_to_ipc_events(result) + tool_logs = [e for e in events if e.event == "tool.log"] + assert len(tool_logs) == 1 + assert tool_logs[0].payload["tool"] == "bash" + assert tool_logs[0].payload["message"] == "hello" + + def test_diff_patches_become_diff_events(self) -> None: + result = RunResult(run_id="r", diff_patches=["patch-1", "patch-2"]) + events = result_to_ipc_events(result) + diff_events = [e for e in events if e.event == "diff"] + assert len(diff_events) == 2 + assert diff_events[0].payload["patch"] == "patch-1" + + def test_changed_files_become_changed_file_events(self) -> None: + result = RunResult( + run_id="r", + changed_files=[ChangedFile(path="a.py", status="added", additions=5, deletions=0)], + ) + events = result_to_ipc_events(result) + cf_events = [e for e in events if e.event == "changed_file"] + assert len(cf_events) == 1 + assert cf_events[0].payload["path"] == "a.py" + assert cf_events[0].payload["additions"] == 5 + + def test_all_events_have_run_id_and_timestamp(self) -> None: + result = RunResult(run_id="r-9", status=RunStatus.COMPLETED) + events = result_to_ipc_events(result) + assert all(e.run_id == "r-9" for e in events) + assert all(isinstance(e.timestamp, float) for e in events) + + +# --------------------------------------------------------------------------- +# 7. ChangedFile / IPCEvent / IPCChangedFile dataclass shape +# --------------------------------------------------------------------------- + + +class TestDataclassShapes: + def test_changed_file_defaults(self) -> None: + cf = ChangedFile(path="a", status="added") + assert cf.additions == 0 + assert cf.deletions == 0 + assert cf.diff_patch == "" + + def test_ipc_event_defaults(self) -> None: + ev = IPCEvent(event="x", run_id="r", timestamp=1.0) + assert ev.payload == {} + + def test_ipc_changed_file_defaults(self) -> None: + cf = IPCChangedFile(path="a", status="added") + assert cf.additions == 0 + assert cf.deletions == 0 + assert cf.absolute_path == "" + + +# --------------------------------------------------------------------------- +# 8. run_id is captured from first event +# --------------------------------------------------------------------------- + + +class TestRunIdCapture: + def test_run_id_from_event_payload(self) -> None: + b = RunBridge() + b.ingest(RunEvent( + type=RunEventType.MESSAGE, + payload={"content": "hi", "run_id": "from-event"}, + )) + result = b.finalize() + assert result.run_id == "from-event" + + def test_run_id_falls_back_to_constructor_arg(self) -> None: + b = RunBridge(run_id="from-ctor") + b.ingest(_ev(RunEventType.MESSAGE, {"content": "hi"})) + result = b.finalize() + assert result.run_id == "from-ctor" diff --git a/tests/executors/test_bridge_cli.py b/tests/executors/test_bridge_cli.py new file mode 100644 index 000000000000..bb9849771409 --- /dev/null +++ b/tests/executors/test_bridge_cli.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +""" +Tests for executors/bridge_cli.py — fixture-based acceptance CLI + D1a-local +review/qa stubs. + +Scope: + - _stub_review_report: produces ReviewReport with correct shape; detects + hardcoded-secret / SQL-injection / no-tests / no-issues heuristics + - _stub_qa_report: produces QAReport with correct shape + - _fixture_happy_path / _fixture_failed_path: produce a non-empty list of + RunEvents each + - _run_fixture: routes fixture through RunBridge and returns a RunResult + - cmd_accept: prints pass/fail for happy-path and failed scenarios + - cmd_logs: prints logs and tool calls + - cmd_changed_files: prints changed files + - cmd_diff: prints diff + - cmd_ipc: trigger-review, trigger-qa, continue, retry, unknown action + - handle_bridge_command: dispatches all subcommands + - Guard: importing bridge_cli does NOT pull in review_handler, review_cli, + or cli (D1a boundary protection) + - Guard: no subprocess / git / DB calls anywhere in bridge_cli + +Strictly no subprocess, no real files, no DB, no model, no real project writes. +Uses tmp_path and capsys only. +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from executors import bridge_cli +from executors.bridge_cli import ( + _fixture_failed_path, + _fixture_happy_path, + _run_fixture, + _stub_qa_report, + _stub_review_report, + cmd_accept, + cmd_changed_files, + cmd_diff, + cmd_ipc, + cmd_logs, + handle_bridge_command, +) +from executors.types import ( + FindingCategory, + QAReport, + QAStatus, + ReviewReport, + ReviewStatus, + RunEvent, + RunEventType, + RunStatus, + Severity, +) + + +# --------------------------------------------------------------------------- +# Async helper +# --------------------------------------------------------------------------- + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# 1. _stub_review_report +# --------------------------------------------------------------------------- + + +class TestStubReviewReport: + def test_basic_shape(self) -> None: + report = _stub_review_report("main-1", "") + assert isinstance(report, ReviewReport) + assert report.review_run_id == "review-main-1-stub" + assert report.executor == "stub" + assert report.total_findings >= 1 + # Empty diff should produce at least an info finding + assert any( + f.severity == Severity.INFO for f in report.findings + ) + + def test_detects_hardcoded_secret(self) -> None: + report = _stub_review_report("m", "password = 'hunter2'") + secret_findings = [ + f for f in report.findings if "secret" in f.title.lower() + ] + assert len(secret_findings) == 1 + assert secret_findings[0].severity == Severity.HIGH + assert secret_findings[0].category == FindingCategory.SECURITY + + def test_detects_sql_injection(self) -> None: + report = _stub_review_report("m", "SELECT * FROM users") + sqli = [f for f in report.findings if "SQL" in f.title] + assert len(sqli) == 1 + assert sqli[0].severity == Severity.CRITICAL + + def test_detects_fstring_injection(self) -> None: + report = _stub_review_report("m", 'query = f"SELECT * FROM {table}"') + sqli = [f for f in report.findings if "SQL" in f.title] + assert len(sqli) == 1 + assert sqli[0].severity == Severity.CRITICAL + + def test_detects_missing_tests(self) -> None: + # Two source files changed, no test file → missing-tests finding. + patch = ( + "diff --git a/a.py b/a.py\n" + "--- a/a.py\n" + "+++ b/a.py\n" + "+x = 1\n" + "diff --git a/b.py b/b.py\n" + "--- a/b.py\n" + "+++ b/b.py\n" + "+y = 2\n" + ) + report = _stub_review_report("m", patch) + no_test_findings = [ + f for f in report.findings + if "test" in f.title.lower() and "coverage" in f.title.lower() + ] + assert len(no_test_findings) == 1 + assert no_test_findings[0].category == FindingCategory.TEST_COVERAGE + + def test_no_test_finding_when_tests_present(self) -> None: + patch = ( + "diff --git a/a.py b/a.py\n" + "diff --git a/test_a.py b/test_a.py\n" + ) + report = _stub_review_report("m", patch) + no_test_findings = [ + f for f in report.findings + if "test" in f.title.lower() and "coverage" in f.title.lower() + ] + assert no_test_findings == [] + + def test_severity_counts_match_findings(self) -> None: + report = _stub_review_report("m", "password = 'x'") # 1 HIGH finding + assert report.high_count == 1 + assert report.total_findings == len(report.findings) + + def test_status_completed(self) -> None: + report = _stub_review_report("m", "anything") + assert report.status == ReviewStatus.COMPLETED + + def test_findings_have_required_ids(self) -> None: + report = _stub_review_report("m", "password = 'x'") + for f in report.findings: + assert f.id + assert f.run_id == "review-m-stub" + assert f.title + + +# --------------------------------------------------------------------------- +# 2. _stub_qa_report +# --------------------------------------------------------------------------- + + +class TestStubQAReport: + def test_basic_shape(self) -> None: + report = _stub_qa_report("main-1", ["a.py", "b.py", "c.py"]) + assert isinstance(report, QAReport) + assert report.qa_run_id == "qa-main-1-stub" + assert report.executor == "stub" + assert report.test_passed == 3 + assert report.test_failed == 0 + assert report.test_skipped == 0 + assert "3" in report.test_output + assert report.risks == [] + + def test_empty_changed_files(self) -> None: + report = _stub_qa_report("m", []) + assert report.test_passed == 0 + assert "0" in report.test_output + + def test_status_completed(self) -> None: + report = _stub_qa_report("m", ["a.py"]) + assert report.status == QAStatus.COMPLETED + + +# --------------------------------------------------------------------------- +# 3. Fixture event generators +# --------------------------------------------------------------------------- + + +class TestFixtures: + def test_happy_path_non_empty(self) -> None: + events = _fixture_happy_path() + assert isinstance(events, list) + assert len(events) > 5 + # Must end with a COMPLETED event + assert events[-1].type == RunEventType.COMPLETED + + def test_happy_path_contains_diff(self) -> None: + events = _fixture_happy_path() + diffs = [e for e in events if e.type == RunEventType.DIFF] + assert len(diffs) >= 1 + assert "diff --git" in diffs[0].payload["patch"] + + def test_failed_path_non_empty(self) -> None: + events = _fixture_failed_path() + assert isinstance(events, list) + assert len(events) >= 3 + # Must end with a FAILED event + assert events[-1].type == RunEventType.FAILED + assert events[-1].payload.get("error_summary") + + +# --------------------------------------------------------------------------- +# 4. _run_fixture +# --------------------------------------------------------------------------- + + +class TestRunFixture: + def test_happy_path_yields_completed_result(self) -> None: + result = _run_fixture("happy-path") + assert isinstance(result, type(_run_fixture("happy-path"))) + assert result.status == RunStatus.COMPLETED + assert len(result.changed_files) >= 1 + + def test_failed_yields_failed_result(self) -> None: + result = _run_fixture("failed") + assert result.status == RunStatus.FAILED + assert result.error_summary + + def test_unknown_scenario_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run_fixture("does-not-exist") + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# 5. cmd_accept +# --------------------------------------------------------------------------- + + +class TestCmdAccept: + def test_happy_path_passes(self, capsys) -> None: + _run(cmd_accept("happy-path")) + captured = capsys.readouterr() + # Should print check marks for each check + assert "passed" in captured.out + assert "Acceptance:" in captured.out + + def test_failed_passes(self, capsys) -> None: + _run(cmd_accept("failed")) + captured = capsys.readouterr() + assert "Acceptance:" in captured.out + + def test_unknown_scenario_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(cmd_accept("no-such-scenario")) + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# 6. cmd_logs / cmd_changed_files / cmd_diff +# --------------------------------------------------------------------------- + + +class TestCmdLogs: + def test_prints_logs_header(self, capsys) -> None: + _run(cmd_logs("happy-path")) + captured = capsys.readouterr() + assert "Logs" in captured.out + assert "Tool Calls" in captured.out + + +class TestCmdChangedFiles: + def test_prints_changed_files(self, capsys) -> None: + _run(cmd_changed_files("happy-path")) + captured = capsys.readouterr() + assert "Changed Files" in captured.out + + def test_empty_for_failed(self, capsys) -> None: + _run(cmd_changed_files("failed")) + captured = capsys.readouterr() + assert "No files changed" in captured.out + + +class TestCmdDiff: + def test_prints_diff(self, capsys) -> None: + _run(cmd_diff("happy-path")) + captured = capsys.readouterr() + assert "Diff" in captured.out + assert "diff --git" in captured.out + + def test_empty_for_failed(self, capsys) -> None: + _run(cmd_diff("failed")) + captured = capsys.readouterr() + assert "No diff available" in captured.out + + +# --------------------------------------------------------------------------- +# 7. cmd_ipc +# --------------------------------------------------------------------------- + + +class TestCmdIPC: + def test_trigger_review_json_shape(self, capsys) -> None: + _run(cmd_ipc("trigger-review")) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["action"] == "review:trigger" + assert data["run_id"].startswith("review-run-main-001-stub") + assert data["status"] == "completed" + assert data["executor"] == "stub" + assert isinstance(data["total_findings"], int) + assert isinstance(data["findings"], list) + for f in data["findings"]: + assert "severity" in f + assert "category" in f + assert "title" in f + + def test_trigger_qa_json_shape(self, capsys) -> None: + _run(cmd_ipc("trigger-qa")) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["action"] == "qa:trigger" + assert data["run_id"].startswith("qa-run-main-001-stub") + assert data["status"] == "completed" + assert data["executor"] == "stub" + assert data["test_passed"] >= 0 + assert "test_failed" in data + assert "test_skipped" in data + + def test_continue_json_shape(self, capsys) -> None: + _run(cmd_ipc("continue", thread_id="t-99")) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["action"] == "run:continue" + assert data["request"]["thread_id"] == "t-99" + assert data["response"]["run_id"].startswith("run-continue-t-99") + + def test_retry_json_shape(self, capsys) -> None: + _run(cmd_ipc("retry", thread_id="t-42")) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["action"] == "run:retry" + assert data["request"]["thread_id"] == "t-42" + assert data["response"]["run_id"].startswith("run-retry-t-42") + assert data["response"]["run_seq"] == 2 + + def test_unknown_action_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(cmd_ipc("not-a-real-action")) + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# 8. handle_bridge_command dispatcher +# --------------------------------------------------------------------------- + + +def _args(**kwargs) -> argparse.Namespace: + """Build a minimal Namespace for handle_bridge_command tests.""" + base = { + "bridge_subcommand": None, + "scenario": "happy-path", + "fixture": "happy-path", + "ipc_action": "continue", + "main_run_id": "run-1", + "thread_id": "thread-1", + } + base.update(kwargs) + return argparse.Namespace(**base) + + +class TestHandleBridgeCommand: + def test_no_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_bridge_command(_args())) + assert exc_info.value.code == 1 + + def test_accept_dispatches(self, capsys) -> None: + _run(handle_bridge_command(_args(bridge_subcommand="accept", scenario="happy-path"))) + assert "Acceptance:" in capsys.readouterr().out + + def test_logs_dispatches(self, capsys) -> None: + _run(handle_bridge_command(_args(bridge_subcommand="logs", fixture="happy-path"))) + assert "Logs" in capsys.readouterr().out + + def test_changed_files_dispatches(self, capsys) -> None: + _run(handle_bridge_command(_args(bridge_subcommand="changed-files", fixture="happy-path"))) + assert "Changed Files" in capsys.readouterr().out + + def test_diff_dispatches(self, capsys) -> None: + _run(handle_bridge_command(_args(bridge_subcommand="diff", fixture="happy-path"))) + assert "Diff" in capsys.readouterr().out + + def test_ipc_dispatches(self, capsys) -> None: + _run(handle_bridge_command(_args(bridge_subcommand="ipc", ipc_action="continue", thread_id="t"))) + out = capsys.readouterr().out + data = json.loads(out) + assert data["action"] == "run:continue" + + def test_unknown_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_bridge_command(_args(bridge_subcommand="not-real"))) + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# 9. Boundary guards — bridge_cli must NOT pull in forbidden modules +# --------------------------------------------------------------------------- + + +class TestBoundaryGuards: + def test_importing_bridge_cli_does_not_pull_review_handler(self) -> None: + # Clear any cached version, then import fresh and inspect sys.modules. + for mod in list(sys.modules): + if mod == "executors.review_handler" or mod.startswith("executors.review_handler."): + del sys.modules[mod] + importlib.reload(bridge_cli) + assert "executors.review_handler" not in sys.modules + # And no submodule under review_handler was pulled in either + review_handler_subs = [ + m for m in sys.modules + if m == "executors.review_handler" + or m.startswith("executors.review_handler.") + ] + assert review_handler_subs == [] + + def test_importing_bridge_cli_does_not_pull_review_cli(self) -> None: + for mod in list(sys.modules): + if mod == "executors.review_cli" or mod.startswith("executors.review_cli."): + del sys.modules[mod] + importlib.reload(bridge_cli) + assert "executors.review_cli" not in sys.modules + + def test_importing_bridge_cli_does_not_pull_cli(self) -> None: + # `executors.cli` is the top-level CLI; this is the integration commit + # we explicitly defer. bridge_cli must not import it. + for mod in list(sys.modules): + if mod == "executors.cli" or mod.startswith("executors.cli."): + del sys.modules[mod] + importlib.reload(bridge_cli) + assert "executors.cli" not in sys.modules + + def test_no_subprocess_in_bridge_cli(self) -> None: + # bridge_cli must not import subprocess + src = Path(bridge_cli.__file__).read_text() + assert "subprocess" not in src, "bridge_cli.py must not import subprocess" + + def test_no_git_invocations_in_bridge_cli(self) -> None: + # No calls to git, no "git " patterns in code. + src = Path(bridge_cli.__file__).read_text() + # 'subprocess.run' is the canonical executor boundary; assert not present + assert "subprocess.run" not in src + assert "subprocess.Popen" not in src + # Inline checks for "git " invocations in any string literal + assert "git diff" not in src + assert "git rev-parse" not in src + + def test_no_db_in_bridge_cli(self) -> None: + src = Path(bridge_cli.__file__).read_text() + assert "sqlite3" not in src + assert "kanban_db" not in src + assert "task_events" not in src + + def test_no_model_invocation(self) -> None: + src = Path(bridge_cli.__file__).read_text() + # No direct call to any model binary + assert "opencode" not in src # would spawn subprocess; forbidden in D1a + assert "claude" not in src.lower() or "claude_code" not in src # no adapter invocation + # Note: docstring / comments may mention "claude-code" as a string + # (e.g. _stub_review_report has no model invocation), so we only + # check for known binary names that would imply subprocess. diff --git a/tests/executors/test_cli.py b/tests/executors/test_cli.py new file mode 100644 index 000000000000..8cc14e7f0c67 --- /dev/null +++ b/tests/executors/test_cli.py @@ -0,0 +1,974 @@ +#!/usr/bin/env python3 +""" +Tests for executors/cli.py (Commit D2 — top-level CLI integration). + +Scope: + - Importing executors.cli has no side effects (no subprocess, no + hermes_cli.* leakage, no real binary lookup) + - create_default_registry() wires all 5 adapters without invoking + any real binary + - The 5 formatters (_format_health_table, _format_health_json, + _format_executor_info, _cmd_list, _cmd_health) render correctly + - _cmd_select with unknown id exits 1; with unavailable executor + exits 1 + - _cmd_route (with --accept) uses the router and prints the + recommendation without reading from stdin + - main() delegates to the correct handle_*_command for each top-level + subcommand (worktree, context, review, qa, inbox, bridge) + - main() delegates to _cmd_* for list, health, info, select, route + - main() with no subcommand exits 1 and prints help + - main() with --help exits 0 + - destructive subcommands (worktree discard --force) preserve the + force flag in the args namespace passed to the handler + - Missing external binaries (claude-code/codex/opencode absent) do + not crash; _cmd_health reports UNAVAILABLE and the CLI exits 0 + - Python-level -m executors.cli --help works end-to-end + +Strictly no subprocess execution of real CLI tools (claude-code, +codex, opencode, deepseek-tui), no model calls, no worktree creation, +no real-repo writes, no hermes_cli.* imports. +""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import inspect +import json +import os +import shutil +import subprocess +import sys +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import executors.cli as cli +from executors.cli import ( + STATUS_ICONS, + _cmd_health, + _cmd_info, + _cmd_list, + _cmd_route, + _cmd_select, + _format_executor_info, + _format_health_json, + _format_health_table, + create_default_registry, + main, +) +from executors.registry import ExecutorRegistry +from executors.types import ( + ExecutorCapabilities, + ExecutorHealthResult, + ExecutorHealthStatus, + ExecutorManifest, + RouterRecommendation, +) + + +# --------------------------------------------------------------------------- +# Async helper +# --------------------------------------------------------------------------- + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# Factory helpers +# --------------------------------------------------------------------------- + + +def _make_manifest( + id: str = "test-exec", + label: str = "Test Executor", + description: str = "Test description", + ui_fidelity: str = "full", + supports_worktree: bool = True, + default_model: str = "test-model", + capabilities: ExecutorCapabilities = None, +) -> ExecutorManifest: + if capabilities is None: + capabilities = ExecutorCapabilities( + structured_tool_calls=True, + native_diff_events=True, + reasoning_blocks=True, + review_gate=True, + streaming="realtime", + ) + return ExecutorManifest( + id=id, + label=label, + description=description, + ui_fidelity=ui_fidelity, + supports_worktree=supports_worktree, + default_model=default_model, + capabilities=capabilities, + ) + + +def _make_health( + id: str = "test-exec", + status: ExecutorHealthStatus = ExecutorHealthStatus.AVAILABLE, + version: str = "1.0.0", + error: str = None, +) -> ExecutorHealthResult: + return ExecutorHealthResult( + executor_id=id, + status=status, + version=version, + error=error, + ) + + +def _build_registry( + specs: List[Dict[str, Any]] = None, + health_overrides: Dict[str, ExecutorHealthResult] = None, +) -> ExecutorRegistry: + """Build a registry from manifest specs and optional health overrides.""" + if specs is None: + specs = [ + {"id": "a", "label": "Alpha"}, + {"id": "b", "label": "Beta", "supports_worktree": False}, + ] + reg = ExecutorRegistry() + for spec in specs: + m = _make_manifest(**spec) + reg.register(m, MagicMock()) + if health_overrides: + for eid, h in health_overrides.items(): + reg.set_health(h) + return reg + + +# --------------------------------------------------------------------------- +# 1. Import side effects +# --------------------------------------------------------------------------- + + +class TestImportSideEffects: + def test_importing_cli_does_not_invoke_shutil_which(self, monkeypatch): + """Importing executors.cli must not call shutil.which on any binary.""" + called_with: List[str] = [] + orig_which = shutil.which + + def spy(cmd, *a, **kw): + called_with.append(cmd) + return orig_which(cmd, *a, **kw) + + monkeypatch.setattr(shutil, "which", spy) + + for mod in list(sys.modules): + if mod == "executors.cli" or mod.startswith("executors.cli."): + del sys.modules[mod] + importlib.import_module("executors.cli") + + assert called_with == [], ( + f"shutil.which was called at import time: {called_with[:5]}" + ) + + def test_importing_cli_does_not_load_hermes_cli(self, monkeypatch): + """Importing executors.cli must not pull in any hermes_cli.* module. + + We use a diff-based check: snapshot sys.modules BEFORE the re-import, + re-import, then assert the *delta* contains no hermes_cli.* entries. + Absolute checks are unreliable because other test files in the same + pytest process may have already imported hermes_cli.* modules. + """ + for mod in list(sys.modules): + if mod == "executors.cli" or mod.startswith("executors.cli."): + del sys.modules[mod] + before = set(sys.modules) + importlib.import_module("executors.cli") + added = set(sys.modules) - before + + leaked = [m for m in added if m.startswith("hermes_cli")] + assert leaked == [], f"hermes_cli modules were imported: {leaked}" + + def test_importing_cli_does_not_spawn_subprocess(self, monkeypatch): + """Importing executors.cli must not call asyncio.create_subprocess_exec.""" + called: List[tuple] = [] + orig_create = asyncio.create_subprocess_exec + + async def spy(*a, **kw): + called.append((a, kw)) + return await orig_create(*a, **kw) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", spy) + + for mod in list(sys.modules): + if mod == "executors.cli" or mod.startswith("executors.cli."): + del sys.modules[mod] + importlib.import_module("executors.cli") + + assert called == [], ( + f"subprocess spawned at import time: {called[:1]}" + ) + + def test_create_default_registry_does_not_invoke_which(self, monkeypatch): + """create_default_registry() must not look up any binary on disk.""" + called_with: List[str] = [] + orig_which = shutil.which + + def spy(cmd, *a, **kw): + called_with.append(cmd) + return orig_which(cmd, *a, **kw) + + monkeypatch.setattr(shutil, "which", spy) + create_default_registry() + assert called_with == [] + + +# --------------------------------------------------------------------------- +# 2. Formatters +# --------------------------------------------------------------------------- + + +class TestFormatters: + def test_health_table_contains_all_ids_and_icons(self): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + "b": _make_health(id="b", status=ExecutorHealthStatus.UNAVAILABLE, error="binary missing"), + }, + ) + out = _format_health_table(reg) + assert "a" in out + assert "b" in out + assert STATUS_ICONS[ExecutorHealthStatus.AVAILABLE] in out + assert STATUS_ICONS[ExecutorHealthStatus.UNAVAILABLE] in out + assert "binary missing" in out + + def test_health_table_truncates_long_error(self): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.UNAVAILABLE, error="x" * 200), + }, + ) + out = _format_health_table(reg) + # The 50-char cap is "..." suffix + assert "..." in out + # And the full 200-char error must not be present + assert "x" * 200 not in out + + def test_health_json_includes_capabilities_block(self): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="2.0"), + }, + ) + out = _format_health_json(reg) + data = json.loads(out) + assert "a" in data + assert data["a"]["status"] == "available" + assert data["a"]["version"] == "2.0" + # capabilities nested + caps = data["a"]["capabilities"] + assert caps["structured_tool_calls"] is True + assert caps["streaming"] == "realtime" + assert "ui_fidelity" in data["a"] + assert "label" in data["a"] + assert "description" in data["a"] + + def test_health_json_handles_all_executors(self): + reg = _build_registry() + out = _format_health_json(reg) + data = json.loads(out) + assert set(data.keys()) == {"a", "b"} + + def test_executor_info_known_id(self): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + }, + ) + out = _format_executor_info("a", reg) + assert "ID: a" in out + assert "Label: Alpha" in out + assert "Description: Test description" in out + assert "Version: 1.0" in out + assert "Worktree: supported" in out + # Capabilities block + assert "structured_tool_calls: True" in out + assert "streaming: realtime" in out + + def test_executor_info_unknown_id_returns_message(self): + reg = _build_registry() + out = _format_executor_info("nope", reg) + assert out == "Unknown executor: nope" + + def test_executor_info_worktree_unsupported(self): + reg = _build_registry( + health_overrides={ + "b": _make_health(id="b", status=ExecutorHealthStatus.UNAVAILABLE, error="no"), + }, + ) + out = _format_executor_info("b", reg) + assert "Worktree: unsupported" in out + assert STATUS_ICONS[ExecutorHealthStatus.UNAVAILABLE] in out + + +# --------------------------------------------------------------------------- +# 3. _cmd_list +# --------------------------------------------------------------------------- + + +class TestCmdList: + def test_list_text_format(self, capsys): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE), + "b": _make_health(id="b", status=ExecutorHealthStatus.UNAVAILABLE, error="x"), + }, + ) + _run(_cmd_list(reg, json_output=False)) + out = capsys.readouterr().out + assert "a" in out + assert "b" in out + assert STATUS_ICONS[ExecutorHealthStatus.AVAILABLE] in out + assert STATUS_ICONS[ExecutorHealthStatus.UNAVAILABLE] in out + + def test_list_json_format_is_parseable(self, capsys): + reg = _build_registry() + _run(_cmd_list(reg, json_output=True)) + out = capsys.readouterr().out + data = json.loads(out) + ids = {e["id"] for e in data} + labels = {e["label"] for e in data} + assert ids == {"a", "b"} + assert labels == {"Alpha", "Beta"} + + +# --------------------------------------------------------------------------- +# 4. _cmd_health +# --------------------------------------------------------------------------- + + +class TestCmdHealth: + def test_health_runs_and_prints_table(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + _run(_cmd_health(reg, json_output=False)) + out = capsys.readouterr().out + assert "Running health checks" in out + assert "a" in out + assert "available" in out + assert "1.0" in out + + def test_health_runs_and_prints_json(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + "b": _make_health(id="b", status=ExecutorHealthStatus.UNAVAILABLE, error="x"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + _run(_cmd_health(reg, json_output=True)) + out = capsys.readouterr().out + # `_cmd_health` prints a "Running health checks..." line and a blank + # line BEFORE the JSON. Strip the prefix before parsing. + json_part = out.split("\n\n", 1)[-1] + data = json.loads(json_part) + assert data["a"]["status"] == "available" + assert data["b"]["status"] == "unavailable" + + def test_health_does_not_crash_when_all_binaries_missing(self, capsys, monkeypatch): + """All-executors-unavailable path: must still print cleanly.""" + specs = [ + {"id": "claude-code", "label": "Claude Code"}, + {"id": "codex-cli", "label": "Codex"}, + {"id": "opencode", "label": "OpenCode"}, + {"id": "deepseek-tui", "label": "DeepSeek"}, + ] + reg = _build_registry( + specs=specs, + health_overrides={ + eid: _make_health( + id=eid, + status=ExecutorHealthStatus.UNAVAILABLE, + error=f"{eid} not found", + ) + for eid in ("claude-code", "codex-cli", "opencode", "deepseek-tui") + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + # Should not raise + _run(_cmd_health(reg, json_output=False)) + out = capsys.readouterr().out + assert "claude-code" in out + assert "codex-cli" in out + assert "not found" in out + + +# --------------------------------------------------------------------------- +# 5. _cmd_info +# --------------------------------------------------------------------------- + + +class TestCmdInfo: + def test_info_known_id(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + _run(_cmd_info(reg, "a")) + out = capsys.readouterr().out + assert "ID: a" in out + + def test_info_unknown_id_prints_message(self, capsys, monkeypatch): + reg = _build_registry() + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + _run(_cmd_info(reg, "nope")) + out = capsys.readouterr().out + assert "Unknown executor" in out + assert "nope" in out + + +# --------------------------------------------------------------------------- +# 6. _cmd_select +# --------------------------------------------------------------------------- + + +class TestCmdSelect: + def test_select_with_known_available_id(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE, version="1.0"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + _run(_cmd_select(reg, "a")) + out = capsys.readouterr().out + assert "Selected: " in out + assert "a" in out + assert "Alpha" in out + assert "Ready to use." in out + + def test_select_with_unknown_id_exits_1(self, capsys, monkeypatch): + reg = _build_registry() + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + with pytest.raises(SystemExit) as exc_info: + _run(_cmd_select(reg, "nope")) + assert exc_info.value.code == 1 + err = capsys.readouterr().err + assert "Unknown executor" in err + assert "nope" in err + + def test_select_with_unavailable_id_exits_1(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.UNAVAILABLE, error="no binary"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + with pytest.raises(SystemExit) as exc_info: + _run(_cmd_select(reg, "a")) + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "WARNING" in out + assert "not available" in out + assert "hermes-local" in out # suggestion + + def test_select_without_id_prints_list(self, capsys, monkeypatch): + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE), + "b": _make_health(id="b", status=ExecutorHealthStatus.UNAVAILABLE, error="missing"), + }, + ) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + with pytest.raises(SystemExit) as exc_info: + _run(_cmd_select(reg, None)) + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "Select an executor" in out + assert "a" in out + assert "b" in out + assert "Cancel" in out + + +# --------------------------------------------------------------------------- +# 7. _cmd_route +# --------------------------------------------------------------------------- + + +class TestCmdRoute: + def _fake_router(self, monkeypatch, rec: RouterRecommendation): + from executors import router as router_mod + from executors.types import TaskCreateContext + from executors.cli import _cmd_route as _route + + class FakeRouter: + def route(self, ctx: TaskCreateContext): + return rec + + def fake_create_default_router(): + return FakeRouter() + + # Patch on the cli module — _cmd_route does a local import + # `from executors.router import create_default_router`, so we + # patch the source module. + monkeypatch.setattr(router_mod, "create_default_router", fake_create_default_router) + + async def fake_check(registry): + return None + monkeypatch.setattr(cli, "check_all_executors_health", fake_check) + + def _args(self, **overrides) -> argparse.Namespace: + base = dict( + title="Fix bug", + goal="make tests pass", + accept="claude-code", + executor=None, + ) + base.update(overrides) + return argparse.Namespace(**base) + + def test_route_with_accept_uses_recommendation(self, capsys, monkeypatch): + rec = RouterRecommendation( + recommended_executor="claude-code", + confidence=0.85, + reason="matches keywords", + alternatives=("opencode",), + source="keyword", + override=False, + ) + self._fake_router(monkeypatch, rec) + + reg = _build_registry( + health_overrides={ + "a": _make_health(id="a", status=ExecutorHealthStatus.AVAILABLE), + }, + ) + _run(_cmd_route(reg, self._args())) + out = capsys.readouterr().out + assert "Recommended: claude-code" in out + assert "Confidence: 85%" in out + assert "Auto-accepted: claude-code" in out + assert "matches keywords" in out + + def test_route_with_user_override(self, capsys, monkeypatch): + rec = RouterRecommendation( + recommended_executor="claude-code", + confidence=0.5, + reason="default", + alternatives=(), + source="keyword", + override=False, + ) + self._fake_router(monkeypatch, rec) + + reg = _build_registry() + _run(_cmd_route(reg, self._args(executor="opencode"))) + out = capsys.readouterr().out + assert "User override: opencode" in out + assert "opencode" in out + + def test_route_does_not_read_stdin_when_accept_set(self, capsys, monkeypatch): + """Critical: --accept path must not call input() (would block in CI).""" + rec = RouterRecommendation( + recommended_executor="claude-code", + confidence=0.5, + reason="x", + alternatives=(), + source="keyword", + override=False, + ) + self._fake_router(monkeypatch, rec) + + reg = _build_registry() + + def explode(*a, **kw): + raise AssertionError("input() should not be called when --accept is set") + + monkeypatch.setattr("builtins.input", explode) + _run(_cmd_route(reg, self._args())) + # If we got here, input() was not called. + + +# --------------------------------------------------------------------------- +# 8. main() entry point — delegation tests +# --------------------------------------------------------------------------- + + +class TestMainDelegation: + """main() must dispatch each top-level command to the correct handler.""" + + def _mock_handlers(self, monkeypatch): + """Replace all handle_*_command with AsyncMocks, return them by name.""" + handles = { + "worktree": AsyncMock(), + "context": AsyncMock(), + "review": AsyncMock(), + "qa": AsyncMock(), + "inbox": AsyncMock(), + "bridge": AsyncMock(), + } + monkeypatch.setattr(cli, "handle_worktree_command", handles["worktree"]) + monkeypatch.setattr(cli, "handle_context_command", handles["context"]) + monkeypatch.setattr(cli, "handle_review_command", handles["review"]) + monkeypatch.setattr(cli, "handle_qa_command", handles["qa"]) + monkeypatch.setattr(cli, "handle_inbox_command", handles["inbox"]) + monkeypatch.setattr(cli, "handle_bridge_command", handles["bridge"]) + return handles + + def test_main_no_args_exits_1_and_prints_help(self, monkeypatch, capsys): + self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", ["executors"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + out = capsys.readouterr().out + # The default argparse usage text starts with "usage:" + assert "usage" in out.lower() or "executors" in out + + def test_main_worktree_delegates(self, monkeypatch): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", ["executors", "worktree", "list"]) + main() + handles["worktree"].assert_awaited_once() + # Worktree handler takes (project_root: Path, args) + call_args = handles["worktree"].call_args + project_root, args = call_args.args + assert isinstance(project_root, Path) + assert args.command == "worktree" + assert args.worktree_subcommand == "list" + + def test_main_worktree_resolves_project_root(self, monkeypatch, tmp_path): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "--project-root", str(tmp_path), "worktree", "list", + ]) + main() + project_root, _ = handles["worktree"].call_args.args + assert project_root == tmp_path.resolve() + + def test_main_worktree_discard_preserves_force_flag(self, monkeypatch): + """The --force gate must flow through to the handler untouched.""" + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "worktree", "discard", "t-1234abcd", "--force", + ]) + main() + _, args = handles["worktree"].call_args.args + assert args.worktree_subcommand == "discard" + assert args.thread_id == "t-1234abcd" + assert args.force is True + + def test_main_worktree_discard_without_force(self, monkeypatch): + """--force defaults to False; handler is responsible for the gate.""" + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "worktree", "discard", "t-1234abcd", + ]) + main() + _, args = handles["worktree"].call_args.args + assert args.force is False + + def test_main_context_delegates(self, monkeypatch, tmp_path): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "--project-root", str(tmp_path), + "context", "show", + ]) + main() + handles["context"].assert_awaited_once() + project_root, args = handles["context"].call_args.args + assert project_root == tmp_path.resolve() + assert args.context_subcommand == "show" + + def test_main_review_delegates(self, monkeypatch): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "review", "parse", "--input", "[]", + ]) + main() + handles["review"].assert_awaited_once() + args = handles["review"].call_args.args[0] + assert args.command == "review" + assert args.review_subcommand == "parse" + assert args.input == "[]" + + def test_main_qa_delegates(self, monkeypatch): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "qa", "build-prompt", "--goal", "test goal", + ]) + main() + handles["qa"].assert_awaited_once() + args = handles["qa"].call_args.args[0] + assert args.command == "qa" + assert args.qa_subcommand == "build-prompt" + assert args.goal == "test goal" + + def test_main_inbox_delegates(self, monkeypatch, tmp_path): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "--project-root", str(tmp_path), + "inbox", "list", + ]) + main() + handles["inbox"].assert_awaited_once() + project_root, args = handles["inbox"].call_args.args + assert project_root == tmp_path.resolve() + assert args.inbox_subcommand == "list" + + def test_main_bridge_delegates(self, monkeypatch): + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", [ + "executors", "bridge", "accept", "--scenario", "happy-path", + ]) + main() + handles["bridge"].assert_awaited_once() + args = handles["bridge"].call_args.args[0] + assert args.command == "bridge" + assert args.bridge_subcommand == "accept" + assert args.scenario == "happy-path" + + def test_main_list_does_not_invoke_handlers(self, monkeypatch, capsys): + """The `list` subcommand should NOT touch worktree/context/etc.""" + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", ["executors", "list"]) + main() + for h in handles.values(): + h.assert_not_awaited() + out = capsys.readouterr().out + # The default registry has these 5 executors + for eid in ("hermes-local", "claude-code", "codex-cli", + "deepseek-tui", "opencode"): + assert eid in out, f"list output missing '{eid}'" + + def test_main_health_does_not_invoke_handlers(self, monkeypatch, capsys): + """The `health` subcommand is its own path; no delegation.""" + handles = self._mock_handlers(monkeypatch) + monkeypatch.setattr(sys, "argv", ["executors", "health", "--json"]) + main() + for h in handles.values(): + h.assert_not_awaited() + out = capsys.readouterr().out + # `_cmd_health` prefixes output with "Running health checks..." + a + # blank line, then the JSON object. Strip the prefix. + json_part = out.split("\n\n", 1)[-1] + data = json.loads(json_part) + assert isinstance(data, dict) + assert len(data) >= 1 + # The default registry's 5 executors should all be in the health map + for eid in ("hermes-local", "claude-code", "codex-cli", + "deepseek-tui", "opencode"): + assert eid in data, f"health output missing '{eid}'" + + +# --------------------------------------------------------------------------- +# 9. Integration: python -m executors.cli --help (hermetic end-to-end) +# --------------------------------------------------------------------------- + + +class TestCliHelp: + def test_python_m_executors_cli_help_exits_0(self): + """Subprocess invocation of --help must succeed.""" + result = subprocess.run( + [sys.executable, "-m", "executors.cli", "--help"], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0 + # argparse help lists top-level subcommands + out = result.stdout + result.stderr + for cmd in ("worktree", "context", "review", "qa", "inbox", "bridge", + "list", "health", "info", "select", "route"): + assert cmd in out, f"--help output missing subcommand '{cmd}'" + + def test_python_m_executors_cli_worktree_help_exits_0(self): + result = subprocess.run( + [sys.executable, "-m", "executors.cli", "worktree", "--help"], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0 + out = result.stdout + result.stderr + for sub in ("create", "status", "merge", "discard", "list", "diff", "files"): + assert sub in out, f"worktree --help missing '{sub}'" + + def test_python_m_executors_cli_bridge_help_exits_0(self): + result = subprocess.run( + [sys.executable, "-m", "executors.cli", "bridge", "--help"], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0 + out = result.stdout + result.stderr + for sub in ("accept", "logs", "changed-files", "diff", "ipc"): + assert sub in out, f"bridge --help missing '{sub}'" + + +# --------------------------------------------------------------------------- +# 10. Real-world resilience: no external binaries → list/health still work +# --------------------------------------------------------------------------- + + +class TestMissingBinariesDoNotCrash: + """Even when every external binary is missing, the CLI surfaces a clean + UNAVAILABLE state instead of raising.""" + + def test_create_default_registry_when_no_binaries_present( + self, monkeypatch, capsys, + ): + # Force shutil.which to return None for every binary the adapters probe + def no_binary(name, *a, **kw): + return None + monkeypatch.setattr(shutil, "which", no_binary) + + reg = create_default_registry() + # All 5 manifests are still registered + ids = [m.id for m in reg.list_executors()] + assert set(ids) == { + "hermes-local", "claude-code", "codex-cli", + "deepseek-tui", "opencode", + } + + def test_list_when_no_binaries_present(self, monkeypatch, capsys): + monkeypatch.setattr(shutil, "which", lambda *a, **kw: None) + reg = create_default_registry() + # No health checks run; default is UNKNOWN. Should still print. + _run(_cmd_list(reg, json_output=False)) + out = capsys.readouterr().out + for eid in ("hermes-local", "claude-code", "codex-cli", + "deepseek-tui", "opencode"): + assert eid in out + + def test_main_list_when_no_binaries_present(self, monkeypatch, capsys): + monkeypatch.setattr(shutil, "which", lambda *a, **kw: None) + monkeypatch.setattr(sys, "argv", ["executors", "list"]) + # Must not raise + main() + out = capsys.readouterr().out + assert "hermes-local" in out + + +# --------------------------------------------------------------------------- +# 11. Boundary guards +# --------------------------------------------------------------------------- + + +class TestBoundaryGuards: + def test_cli_does_not_import_kanban_feedback(self): + src = Path(cli.__file__).read_text() + assert "kanban_feedback" not in src + assert "hermes_cli" not in src + + def test_cli_does_not_import_uncommitted_modules(self): + """D2 must only import modules committed in A/B/C1/C2/D1a/D1b.""" + committed = { + "executors.types", "executors.registry", "executors.health", + "executors.hermes_local_adapter", "executors.claude_code_adapter", + "executors.codex_adapter", "executors.deepseek_tui_adapter", + "executors.opencode_adapter", + "executors.worktree_cli", "executors.context_cli", + "executors.review_cli", "executors.inbox_cli", "executors.bridge_cli", + "executors.router", # used inside _cmd_route via local import + } + # Parse the source for `from executors.X import` and `import executors.X` + import re + src = Path(cli.__file__).read_text() + for pattern in ( + r"^from executors\.(\w+) import", + r"^import executors\.(\w+)", + ): + for match in re.finditer(pattern, src, re.MULTILINE): + mod = f"executors.{match.group(1)}" + assert mod in committed, ( + f"cli.py imports uncommitted module: {mod}" + ) + + def test_cli_does_not_import_worktree(self): + """D2 must NOT import executors.worktree directly — only via worktree_cli.""" + src = Path(cli.__file__).read_text() + # Allow the argparse help text but not an actual import statement + import re + for line in src.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + continue + # Reject any import of worktree directly + assert not re.match( + r"^(?:from|import)\s+executors\.worktree(?:\s|$)", + stripped, + ), f"cli.py must not import executors.worktree: {stripped!r}" + + def test_cli_does_not_import_subprocess_at_top_level(self): + """No real subprocess calls at import; subprocess is only inside + adapter methods, not the CLI module itself.""" + src = Path(cli.__file__).read_text() + assert "import subprocess" not in src + assert "from subprocess" not in src + assert "asyncio.create_subprocess_exec" not in src + + def test_cli_does_not_import_asyncio_subprocess(self): + """Even via re-export: no subprocess at the CLI level.""" + import ast + tree = ast.parse(Path(cli.__file__).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert "subprocess" not in alias.name + if isinstance(node, ast.ImportFrom): + if node.module: + assert "subprocess" not in node.module diff --git a/tests/executors/test_context.py b/tests/executors/test_context.py new file mode 100644 index 000000000000..e58fa9bbdaee --- /dev/null +++ b/tests/executors/test_context.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +""" +Tests for executors/context.py — WorkspaceContextManager. + +Scope: + - Round-trip JSON load/save in tmp_path + - Missing context file returns graceful default + - Field-level CRUD (overview, architecture, adr, sprint, command, etc.) + - recent_tasks cap at MAX_RECENT_TASKS (10), preserves ordering + - context_hash is deterministic + 16-char hex + - Malformed JSON is handled gracefully (warning, fallback to default) + - Does NOT write to the real repo /Users/gu/.hermes/hermes-agent + - Does NOT create worktrees + - Does NOT spawn subprocesses + +Strictly no live network calls, no model invocations, no worktree creation. +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path + +import pytest + +from executors.context import ( + CONTEXT_FILENAME, + MAX_RECENT_TASKS, + WorkspaceContextManager, + create_context_manager, +) +from executors.types import ( + AdrSummary, + CommandEntry, + ProjectContext, + RecentTask, +) + + +# --------------------------------------------------------------------------- +# 1. Round-trip JSON load / save +# --------------------------------------------------------------------------- + +class TestContextRoundTrip: + def test_save_then_load_round_trips_all_fields(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("A modular agent system") + mgr.set_architecture("Event-driven core; CLI + Gateway front-ends") + mgr.set_sprint("Sprint 47 — context injection") + mgr.set_conventions("PEP 8 + type hints everywhere") + mgr.add_adr("ADR-001", "Use Redis", "Use Redis for cross-process caching") + mgr.add_adr("ADR-002", "Drop YAML", "Drop YAML in favor of JSON") + mgr.add_common_command("build", "make build") + mgr.add_common_command("test", "make test") + mgr.add_test_command("unit", "pytest tests/unit") + mgr.add_forbidden_area("secrets/") + mgr.add_recent_task(RecentTask( + thread_id="t-1", title="Add router", executor="claude-code", + status="done", completed_at="2026-06-01T10:00:00Z", + )) + mgr.set_injection_enabled(True) + + # File should exist after set_* calls (each calls save()). + ctx_path = tmp_path / ".hermes" / CONTEXT_FILENAME + assert ctx_path.exists() + raw = json.loads(ctx_path.read_text()) + assert raw["project_overview"] == "A modular agent system" + assert raw["current_sprint"] == "Sprint 47 — context injection" + assert len(raw["adr_summaries"]) == 2 + assert raw["adr_summaries"][0]["id"] == "ADR-001" + assert raw["common_commands"][1]["command"] == "make test" + assert raw["forbidden_areas"] == ["secrets/"] + assert raw["context_injection_enabled"] is True + + # Round-trip: a fresh manager reading the same file should see all data. + mgr2 = WorkspaceContextManager(tmp_path) + ctx2 = mgr2.load() + assert ctx2.project_overview == "A modular agent system" + assert ctx2.architecture_notes.startswith("Event-driven") + assert ctx2.current_sprint == "Sprint 47 — context injection" + assert len(ctx2.adr_summaries) == 2 + assert ctx2.adr_summaries[1].title == "Drop YAML" + assert len(ctx2.common_commands) == 2 + assert len(ctx2.test_commands) == 1 + assert ctx2.forbidden_areas == ["secrets/"] + assert ctx2.coding_conventions == "PEP 8 + type hints everywhere" + assert len(ctx2.recent_tasks) == 1 + assert ctx2.recent_tasks[0].thread_id == "t-1" + assert ctx2.context_injection_enabled is True + + def test_save_creates_hermes_dir_if_missing(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + assert not (tmp_path / ".hermes").exists() + mgr.set_overview("hi") + assert (tmp_path / ".hermes").is_dir() + assert (tmp_path / ".hermes" / CONTEXT_FILENAME).is_file() + + def test_save_writes_valid_json(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_sprint("S1") + path = tmp_path / ".hermes" / CONTEXT_FILENAME + # Must not raise. + data = json.loads(path.read_text()) + assert isinstance(data, dict) + assert "current_sprint" in data + + +# --------------------------------------------------------------------------- +# 2. Missing file graceful default +# --------------------------------------------------------------------------- + +class TestMissingFileGracefulDefault: + def test_load_without_file_returns_default(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + assert not mgr.is_loaded() + ctx = mgr.load() + assert isinstance(ctx, ProjectContext) + assert ctx.project_overview == "" + assert ctx.architecture_notes == "" + assert ctx.adr_summaries == [] + assert ctx.current_sprint == "" + assert ctx.common_commands == [] + assert ctx.test_commands == [] + assert ctx.forbidden_areas == [] + assert ctx.coding_conventions == "" + assert ctx.recent_tasks == [] + assert ctx.context_injection_enabled is True + # No file was created by read-only load(). + assert not (tmp_path / ".hermes" / CONTEXT_FILENAME).exists() + + def test_get_context_loads_lazily(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + assert not mgr.is_loaded() + _ = mgr.get_context() + assert mgr.is_loaded() + + def test_set_context_before_load_still_works(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + new_ctx = ProjectContext(project_overview="preset") + mgr.set_context(new_ctx) + ctx2 = WorkspaceContextManager(tmp_path).load() + assert ctx2.project_overview == "preset" + + +# --------------------------------------------------------------------------- +# 3. Field-level CRUD +# --------------------------------------------------------------------------- + +class TestFieldCRUD: + def test_overview_round_trip(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("alpha") + assert mgr.get_overview() == "alpha" + mgr.set_overview("beta") + assert mgr.get_overview() == "beta" + + def test_architecture_round_trip(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_architecture("hex arch") + assert mgr.get_architecture() == "hex arch" + + def test_adr_add_remove(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.add_adr("ADR-1", "First", "Use it") + mgr.add_adr("ADR-2", "Second", "Use it too") + ids = [a.id for a in mgr.get_adrs()] + assert ids == ["ADR-1", "ADR-2"] + mgr.remove_adr("ADR-1") + ids = [a.id for a in mgr.get_adrs()] + assert ids == ["ADR-2"] + mgr.remove_adr("DOES-NOT-EXIST") + ids = [a.id for a in mgr.get_adrs()] + assert ids == ["ADR-2"] # no-op, not an error + + def test_sprint_round_trip(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_sprint("S47") + assert mgr.get_sprint() == "S47" + + def test_common_command_add_remove(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.add_common_command("build", "make build") + mgr.add_common_command("lint", "ruff check") + labels = [c.label for c in mgr.get_common_commands()] + assert labels == ["build", "lint"] + mgr.remove_common_command("build") + assert [c.label for c in mgr.get_common_commands()] == ["lint"] + + def test_test_commands_add_only(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.add_test_command("unit", "pytest -q") + assert [c.label for c in mgr.get_test_commands()] == ["unit"] + + def test_forbidden_area_dedup(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.add_forbidden_area("secrets/") + mgr.add_forbidden_area("secrets/") # dedup — must NOT save again + assert mgr.get_forbidden_areas() == ["secrets/"] + mgr.add_forbidden_area("node_modules/") + assert mgr.get_forbidden_areas() == ["secrets/", "node_modules/"] + mgr.remove_forbidden_area("secrets/") + assert mgr.get_forbidden_areas() == ["node_modules/"] + + def test_conventions_round_trip(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_conventions("PEP 8") + assert mgr.get_conventions() == "PEP 8" + + def test_injection_enabled_toggle(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + assert mgr.get_injection_enabled() is True + mgr.set_injection_enabled(False) + assert mgr.get_injection_enabled() is False + mgr.set_injection_enabled(True) + assert mgr.get_injection_enabled() is True + + +# --------------------------------------------------------------------------- +# 4. recent_tasks cap and ordering +# --------------------------------------------------------------------------- + +class TestRecentTasksCapAndOrdering: + def test_recent_tasks_capped_at_max(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + for i in range(MAX_RECENT_TASKS + 5): + mgr.add_recent_task(RecentTask( + thread_id=f"t-{i:03d}", + title=f"Task {i:03d}", + executor="claude-code", + status="done", + completed_at="2026-06-01T00:00:00Z", + )) + tasks = mgr.get_recent_tasks() + assert len(tasks) == MAX_RECENT_TASKS + # The cap keeps the LAST N entries in order (most recent at the end). + first_id = tasks[0].thread_id + last_id = tasks[-1].thread_id + assert first_id == f"t-{5:03d}" # 15 added, 10 kept, drop 5 oldest + assert last_id == f"t-{14:03d}" + # Order is preserved. + for i, t in enumerate(tasks): + assert t.thread_id == f"t-{i + 5:03d}" + + def test_recent_tasks_under_cap_keeps_all(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + for i in range(3): + mgr.add_recent_task(RecentTask( + thread_id=f"t-{i}", title=f"T{i}", executor="hermes-local", + status="done", completed_at="2026-06-01T00:00:00Z", + )) + assert len(mgr.get_recent_tasks()) == 3 + + def test_recent_tasks_loaded_from_disk_respect_cap( + self, tmp_path: Path + ) -> None: + # Write 15 tasks directly to JSON; the manager should accept up to 10 + # visible on read (via add_recent_task's cap), but a fresh load returns + # whatever is on disk verbatim. The cap is enforced only on append. + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("seed") + mgr.add_recent_task(RecentTask( + thread_id="t-0", title="T0", executor="x", status="done", + completed_at="2026-06-01T00:00:00Z", + )) + # Bypass add_recent_task and inject more via set_context. + ctx = mgr.get_context() + for i in range(1, 15): + ctx.recent_tasks.append(RecentTask( + thread_id=f"t-{i}", title=f"T{i}", executor="x", status="done", + completed_at="2026-06-01T00:00:00Z", + )) + mgr.save() + # Re-open and verify the raw file has 15; cap is enforced only on append. + ctx2 = WorkspaceContextManager(tmp_path).load() + assert len(ctx2.recent_tasks) == 15 + + +# --------------------------------------------------------------------------- +# 5. context_hash +# --------------------------------------------------------------------------- + +class TestContextHash: + def test_hash_is_16_char_hex(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("anything") + h = mgr.context_hash() + assert isinstance(h, str) + assert len(h) == 16 + assert re.fullmatch(r"[0-9a-f]{16}", h), f"non-hex hash: {h!r}" + + def test_hash_changes_with_content(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("a") + h1 = mgr.context_hash() + mgr.set_overview("b") + h2 = mgr.context_hash() + assert h1 != h2 + + def test_hash_is_deterministic_for_same_content(self, tmp_path: Path) -> None: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("same") + h1 = mgr.context_hash() + h2 = mgr.context_hash() + assert h1 == h2 + + +# --------------------------------------------------------------------------- +# 6. Malformed JSON graceful handling +# --------------------------------------------------------------------------- + +class TestMalformedJsonGraceful: + def test_corrupt_json_returns_default(self, tmp_path: Path) -> None: + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + (hermes_dir / CONTEXT_FILENAME).write_text("{ not valid json") + mgr = WorkspaceContextManager(tmp_path) + ctx = mgr.load() + assert isinstance(ctx, ProjectContext) + # Should fall back to defaults. + assert ctx.project_overview == "" + assert ctx.current_sprint == "" + + def test_wrong_type_json_raises(self, tmp_path: Path) -> None: + """Non-dict JSON (e.g. a list) is malformed input. The manager + treats raw as a dict, so a list causes AttributeError on .get(). + Documented behavior: the caller is expected to delete / fix the + corrupt file. This is acceptable — load() must not silently + discard user data. + """ + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + (hermes_dir / CONTEXT_FILENAME).write_text("[]") # list, not object + mgr = WorkspaceContextManager(tmp_path) + with pytest.raises(AttributeError): + mgr.load() + + def test_empty_file_returns_default(self, tmp_path: Path) -> None: + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + (hermes_dir / CONTEXT_FILENAME).write_text("") + mgr = WorkspaceContextManager(tmp_path) + ctx = mgr.load() + assert ctx.project_overview == "" + + +# --------------------------------------------------------------------------- +# 7. create_context_manager factory +# --------------------------------------------------------------------------- + +class TestContextFactory: + def test_factory_creates_workspace_manager(self, tmp_path: Path) -> None: + mgr = create_context_manager(tmp_path) + assert isinstance(mgr, WorkspaceContextManager) + assert mgr._project_root == tmp_path.resolve() + + +# --------------------------------------------------------------------------- +# 8. No real-repo writes / no worktree / no subprocess +# --------------------------------------------------------------------------- + +class TestNoSideEffects: + def test_does_not_write_outside_project_root( + self, tmp_path: Path, monkeypatch + ) -> None: + """All writes must land under tmp_path. The real HOME/HERMES_HOME are + re-pointed at a different tmp_path to confirm no leakage. + """ + real_cwd = Path.cwd() + real_home = Path.home() + leak_root = tmp_path / "leak_check" + leak_root.mkdir() + leak_hermes = leak_root / "leak_hermes" + monkeypatch.setenv("HOME", str(leak_root)) + monkeypatch.setenv("HERMES_HOME", str(leak_hermes)) + + try: + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("hi") + mgr.set_sprint("S1") + mgr.add_adr("ADR-1", "T", "D") + mgr.add_recent_task(RecentTask( + thread_id="t-1", title="T", executor="x", status="done", + completed_at="2026-06-01T00:00:00Z", + )) + + # All writes should be under tmp_path/.hermes. + assert (tmp_path / ".hermes" / CONTEXT_FILENAME).exists() + + # No files should appear under the leak-detector path. + leak_files = [p for p in leak_root.rglob("*") if p.is_file()] + assert leak_files == [], ( + f"Unexpected files outside project_root: {leak_files}" + ) + # HERMES_HOME should not have been created/touched. + assert not leak_hermes.exists() or not any( + p.is_file() for p in leak_hermes.rglob("*") + ), "HERMES_HOME was unexpectedly touched" + finally: + if Path.cwd() != real_cwd: + os.chdir(real_cwd) + + def test_does_not_import_worktree(self) -> None: + """Importing context.py must not pull in executors.worktree.""" + import sys + # Clear cached modules so we observe fresh imports. We must also + # drop executors.worktree (if already loaded by an earlier test + # in the same session) so the assertion below observes only + # what context.py's re-import pulls in. + for mod_name in list(sys.modules): + if mod_name in ("executors.context", "executors.worktree"): + del sys.modules[mod_name] + import executors.context # noqa: F401 + # The worktree module must remain unloaded. + assert "executors.worktree" not in sys.modules + + def test_does_not_call_subprocess(self, tmp_path: Path, monkeypatch) -> None: + """No subprocess invocation should occur during CRUD.""" + import subprocess + + popen_calls: list = [] + original_popen = subprocess.Popen + + def tracking_popen(*args, **kwargs): + popen_calls.append((args, kwargs)) + return original_popen(*args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracking_popen) + + mgr = WorkspaceContextManager(tmp_path) + mgr.set_overview("x") + mgr.set_architecture("y") + mgr.add_adr("ADR-1", "T", "D") + mgr.add_common_command("build", "echo build") + mgr.add_recent_task(RecentTask( + thread_id="t-1", title="T", executor="x", status="done", + completed_at="2026-06-01T00:00:00Z", + )) + + assert popen_calls == [], f"Unexpected subprocess calls: {popen_calls}" diff --git a/tests/executors/test_inbox.py b/tests/executors/test_inbox.py new file mode 100644 index 000000000000..87f7152270df --- /dev/null +++ b/tests/executors/test_inbox.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" +Tests for executors/inbox.py — InboxManager. + +Scope: + - Add / list / update / convert / reject / archive / expire round-trip + - Filters (status, source) on list_items + - get_writeback_callback round-trip + - writeback_destination descriptive string per source + - count_by_status / count_pending_by_source + - Corrupt JSON graceful handling + - Does NOT write to the real repo /Users/gu/.hermes/hermes-agent + - Does NOT create worktrees + - Does NOT spawn subprocesses + - Does NOT call any model + - Does NOT actually write to ~/.hermes/inbox-results/ (only returns + a descriptive destination string) + +Strictly no live network calls, no model invocations, no worktree creation. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import List + +import pytest + +from executors.inbox import INBOX_FILENAME, InboxManager, create_inbox_manager +from executors.types import ( + InboxItem, + InboxResultCallback, + InboxSource, + InboxStatus, + TaskDraft, +) + + +# --------------------------------------------------------------------------- +# 1. Add / list round-trip +# --------------------------------------------------------------------------- + +class TestAddListRoundTrip: + def test_add_creates_pending_item(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add( + source=InboxSource.CLI, + title="Fix login bug", + body="Login flow fails when password contains '@'", + ) + assert isinstance(item, InboxItem) + assert item.id.startswith("inbox-") + assert item.source == InboxSource.CLI + assert item.status == InboxStatus.PENDING + assert item.draft.title == "Fix login bug" + assert item.draft.suggested_prompt == "Login flow fails when password contains '@'" + assert item.draft.priority == "normal" + assert item.draft.user_edited is False + assert item.linked_task_id is None + assert item.rejected_reason is None + # The inbox.json file should have been written. + assert (tmp_path / ".hermes" / INBOX_FILENAME).exists() + + def test_list_returns_all_items(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.DESKTOP, "T2", "B2") + mgr.add(InboxSource.FEISHU, "T3", "B3") + items = mgr.list_items() + assert len(items) == 3 + titles = sorted(it.draft.title for it in items) + assert titles == ["T1", "T2", "T3"] + + def test_list_filters_by_status(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + a = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.CLI, "T2", "B2") + mgr.convert_to_task(a.id, "task-99") + pending = mgr.list_items(status=InboxStatus.PENDING) + confirmed = mgr.list_items(status=InboxStatus.CONFIRMED) + assert len(pending) == 1 + assert pending[0].draft.title == "T2" + assert len(confirmed) == 1 + assert confirmed[0].draft.title == "T1" + + def test_list_filters_by_source(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.DESKTOP, "T2", "B2") + cli_items = mgr.list_items(source=InboxSource.CLI) + assert len(cli_items) == 1 + assert cli_items[0].draft.title == "T1" + + def test_list_combines_status_and_source_filters(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.DESKTOP, "T2", "B2") + cli_pending = mgr.list_items( + status=InboxStatus.PENDING, source=InboxSource.CLI, + ) + assert len(cli_pending) == 1 + # CLI confirmed is empty. + cli_confirmed = mgr.list_items( + status=InboxStatus.CONFIRMED, source=InboxSource.CLI, + ) + assert cli_confirmed == [] + + def test_list_pending_helper(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + a = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.CLI, "T2", "B2") + mgr.convert_to_task(a.id, "task-1") + pending = mgr.list_pending() + assert len(pending) == 1 + assert pending[0].draft.title == "T2" + + +# --------------------------------------------------------------------------- +# 2. State transitions +# --------------------------------------------------------------------------- + +class TestStateTransitions: + def test_convert_to_task_marks_confirmed(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + result = mgr.convert_to_task(item.id, "task-xyz") + assert result is not None + assert result.status == InboxStatus.CONFIRMED + assert result.linked_task_id == "task-xyz" + # Re-reading from disk should reflect the new status. + reread = mgr.get(item.id) + assert reread.status == InboxStatus.CONFIRMED + assert reread.linked_task_id == "task-xyz" + + def test_convert_unknown_id_returns_none(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + assert mgr.convert_to_task("does-not-exist", "task-1") is None + + def test_convert_already_confirmed_returns_existing( + self, tmp_path: Path + ) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.convert_to_task(item.id, "task-1") + # Second convert should not raise; returns the existing item. + result = mgr.convert_to_task(item.id, "task-2") + assert result is not None + # Original linked_task_id is preserved. + assert result.linked_task_id == "task-1" + + def test_reject_sets_rejected_status(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.reject(item.id, "out of scope") + reread = mgr.get(item.id) + assert reread.status == InboxStatus.REJECTED + assert reread.rejected_reason == "out of scope" + + def test_archive_sets_archived_status(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.archive(item.id) + assert mgr.get(item.id).status == InboxStatus.ARCHIVED + + def test_expire_sets_expired_status(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.expire(item.id) + assert mgr.get(item.id).status == InboxStatus.EXPIRED + + def test_reject_unknown_returns_none(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + assert mgr.reject("nope") is None + assert mgr.archive("nope") is None + assert mgr.expire("nope") is None + + +# --------------------------------------------------------------------------- +# 3. update_draft +# --------------------------------------------------------------------------- + +class TestUpdateDraft: + def test_update_title_marks_user_edited(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + assert item.draft.user_edited is False + mgr.update_draft(item.id, title="New Title") + reread = mgr.get(item.id) + assert reread.draft.title == "New Title" + assert reread.draft.user_edited is True + + def test_update_prompt_marks_user_edited(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.update_draft(item.id, prompt="New prompt body") + reread = mgr.get(item.id) + assert reread.draft.suggested_prompt == "New prompt body" + assert reread.draft.user_edited is True + + def test_update_executor_does_not_set_user_edited( + self, tmp_path: Path + ) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.update_draft(item.id, executor="codex-cli") + reread = mgr.get(item.id) + assert reread.draft.suggested_executor == "codex-cli" + # Per the source: only title/prompt changes set user_edited. + assert reread.draft.user_edited is False + + def test_update_project_and_priority(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.update_draft(item.id, project="repo-x", priority="high") + reread = mgr.get(item.id) + assert reread.draft.project_hint == "repo-x" + assert reread.draft.priority == "high" + + def test_update_unknown_returns_none(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + assert mgr.update_draft("nope", title="x") is None + + def test_no_args_keeps_existing(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add( + InboxSource.CLI, "T1", "B1", + suggested_executor="claude-code", project_hint="repo-x", priority="high", + ) + # Call with no overrides — all fields stay the same. + mgr.update_draft(item.id) + reread = mgr.get(item.id) + assert reread.draft.title == "T1" + assert reread.draft.suggested_executor == "claude-code" + + +# --------------------------------------------------------------------------- +# 4. Writeback callback +# --------------------------------------------------------------------------- + +class TestWritebackCallback: + def test_cli_source_writeback_available(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + cb = mgr.get_writeback_callback( + item.id, run_id="run-1", summary="done", + ) + assert isinstance(cb, InboxResultCallback) + assert cb.inbox_item_id == item.id + assert cb.run_id == "run-1" + assert cb.status == "done" + assert cb.summary == "done" + assert cb.writeback_available is True + + def test_desktop_source_writeback_not_available(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.DESKTOP, "T1", "B1") + cb = mgr.get_writeback_callback( + item.id, run_id="run-1", summary="done", + ) + assert cb is not None + assert cb.writeback_available is False + + def test_stub_source_writeback_not_available(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + for source in (InboxSource.FEISHU, InboxSource.DISCORD, InboxSource.SCHEDULER): + item = mgr.add(source, "T", "B") + cb = mgr.get_writeback_callback( + item.id, run_id="run-1", summary="done", + ) + assert cb is not None + assert cb.writeback_available is False, f"{source} should be unavailable" + + def test_summary_truncated_to_500_chars(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + long_summary = "x" * 2000 + cb = mgr.get_writeback_callback( + item.id, run_id="run-1", summary=long_summary, + ) + assert len(cb.summary) == 500 + + def test_callback_for_unknown_id_returns_none(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + assert mgr.get_writeback_callback( + "does-not-exist", run_id="r", summary="s", + ) is None + + def test_writeback_destination_describes_path_only( + self, tmp_path: Path, monkeypatch + ) -> None: + """writeback_destination returns a descriptive string. It must NOT + actually write a file to ~/.hermes/inbox-results/. + """ + # Redirect HOME so we can detect any writes. + leak_root = tmp_path / "leak_check" + leak_root.mkdir() + leak_hermes = leak_root / ".hermes" + monkeypatch.setenv("HOME", str(leak_root)) + + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + dest = InboxManager.writeback_destination(item) + # The string contains the home-dir-marker; we don't write, just return. + assert "inbox-results" in dest + # Confirm no file actually got created. + results_dir = leak_root / ".hermes" / "inbox-results" + assert not results_dir.exists(), ( + f"Unexpected write to {results_dir} — writeback_destination must be descriptive only" + ) + + def test_writeback_destination_per_source(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + for source, expected_substr in [ + (InboxSource.DESKTOP, "manual"), + (InboxSource.CLI, "inbox-results"), + (InboxSource.FEISHU, "unavailable"), + (InboxSource.DISCORD, "unavailable"), + (InboxSource.SCHEDULER, "unavailable"), + ]: + item = mgr.add(source, "T", "B") + dest = InboxManager.writeback_destination(item) + assert expected_substr in dest.lower(), ( + f"source {source}: dest {dest!r} missing {expected_substr!r}" + ) + + +# --------------------------------------------------------------------------- +# 5. Counts +# --------------------------------------------------------------------------- + +class TestCounts: + def test_count_by_status(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + a = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.CLI, "T2", "B2") + mgr.add(InboxSource.CLI, "T3", "B3") + mgr.convert_to_task(a.id, "task-1") + mgr.reject(mgr.list_items(status=InboxStatus.PENDING)[0].id, "x") + counts = mgr.count_by_status() + assert counts["pending"] == 1 + assert counts["confirmed"] == 1 + assert counts["rejected"] == 1 + + def test_count_pending_by_source(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.CLI, "T2", "B2") + mgr.add(InboxSource.DESKTOP, "T3", "B3") + counts = mgr.count_pending_by_source() + assert counts["cli"] == 2 + assert counts["desktop"] == 1 + + +# --------------------------------------------------------------------------- +# 6. Persistence round-trip +# --------------------------------------------------------------------------- + +class TestPersistence: + def test_persistence_round_trip(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + item = mgr.add( + InboxSource.CLI, "Refactor auth", "Refactor the auth module", + suggested_executor="codex-cli", project_hint="repo-x", + priority="high", + ) + mgr2 = InboxManager(tmp_path) + items = mgr2.list_items() + assert len(items) == 1 + assert items[0].id == item.id + assert items[0].draft.title == "Refactor auth" + assert items[0].draft.suggested_executor == "codex-cli" + assert items[0].draft.priority == "high" + + def test_serialization_includes_all_fields(self, tmp_path: Path) -> None: + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T", "B") + path = tmp_path / ".hermes" / INBOX_FILENAME + data = json.loads(path.read_text()) + assert isinstance(data, list) + assert len(data) == 1 + record = data[0] + for required in ( + "id", "source", "raw_payload", "draft", "status", + "created_at", "expires_at", "linked_task_id", "rejected_reason", + ): + assert required in record, f"missing field: {required}" + assert record["source"] == "cli" + assert record["status"] == "pending" + + +# --------------------------------------------------------------------------- +# 7. Malformed JSON graceful handling +# --------------------------------------------------------------------------- + +class TestMalformedJsonGraceful: + def test_corrupt_json_returns_empty(self, tmp_path: Path) -> None: + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + (hermes_dir / INBOX_FILENAME).write_text("{ not json") + mgr = InboxManager(tmp_path) + assert mgr.list_items() == [] + # Subsequent add should still work. + mgr.add(InboxSource.CLI, "T1", "B1") + assert len(mgr.list_items()) == 1 + + def test_empty_file_returns_empty(self, tmp_path: Path) -> None: + hermes_dir = tmp_path / ".hermes" + hermes_dir.mkdir() + (hermes_dir / INBOX_FILENAME).write_text("") + mgr = InboxManager(tmp_path) + assert mgr.list_items() == [] + + +# --------------------------------------------------------------------------- +# 8. Factory +# --------------------------------------------------------------------------- + +class TestFactory: + def test_factory_creates_inbox_manager(self, tmp_path: Path) -> None: + mgr = create_inbox_manager(tmp_path) + assert isinstance(mgr, InboxManager) + assert mgr._project_root == tmp_path.resolve() + + +# --------------------------------------------------------------------------- +# 9. No side effects +# --------------------------------------------------------------------------- + +class TestNoSideEffects: + def test_does_not_write_outside_project_root( + self, tmp_path: Path, monkeypatch + ) -> None: + """All inbox writes must land under tmp_path. The real HOME/HERMES_HOME + are re-pointed to confirm no leakage to ~/.hermes/ or similar. + """ + leak_root = tmp_path / "leak_check" + leak_root.mkdir() + leak_hermes = leak_root / "leak_hermes" + monkeypatch.setenv("HOME", str(leak_root)) + monkeypatch.setenv("HERMES_HOME", str(leak_hermes)) + + mgr = InboxManager(tmp_path) + item = mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.DESKTOP, "T2", "B2") + mgr.update_draft(item.id, title="new") + mgr.convert_to_task(item.id, "task-1") + + # Inbox file is under the project root, not the leak detector. + assert (tmp_path / ".hermes" / INBOX_FILENAME).exists() + # And writeback_destination must not have written anything. + InboxManager.writeback_destination(mgr.get(item.id)) + results_dir = leak_root / ".hermes" / "inbox-results" + assert not results_dir.exists() + # HERMES_HOME untouched. + if leak_hermes.exists(): + assert not any(p.is_file() for p in leak_hermes.rglob("*")) + + def test_does_not_call_subprocess( + self, tmp_path: Path, monkeypatch + ) -> None: + popen_calls: list = [] + original = subprocess.Popen + + def tracking(*args, **kwargs): + popen_calls.append((args, kwargs)) + return original(*args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracking) + + mgr = InboxManager(tmp_path) + mgr.add(InboxSource.CLI, "T1", "B1") + mgr.add(InboxSource.DESKTOP, "T2", "B2") + mgr.update_draft(mgr.list_items()[0].id, title="x") + mgr.convert_to_task(mgr.list_items()[0].id, "t-1") + mgr.reject(mgr.list_items()[0].id, "x") + mgr.archive(mgr.list_items()[0].id) + + assert popen_calls == [], f"Unexpected subprocess: {popen_calls}" + + def test_does_not_import_worktree(self) -> None: + import sys + # Drop executors.worktree (if already loaded by an earlier test + # in the same session) so the assertion below observes only + # what inbox.py's re-import pulls in. + for mod_name in list(sys.modules): + if mod_name in ("executors.inbox", "executors.worktree"): + del sys.modules[mod_name] + import executors.inbox # noqa: F401 + assert "executors.worktree" not in sys.modules diff --git a/tests/executors/test_ipc.py b/tests/executors/test_ipc.py new file mode 100644 index 000000000000..8e8e854cf13e --- /dev/null +++ b/tests/executors/test_ipc.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Tests for executors/ipc.py — IPC protocol dataclasses + channel catalog. + +Scope: + - All Request / Response dataclasses instantiate with required + default fields + - Default values match the docstring contract + - Serialization shape (dataclasses.asdict) is JSON-friendly + - HermesAPI class is a documentation stub (inert, non-instantiable by contract) + - IPC_CHANNELS catalog covers every Request dataclass + +Strictly no subprocess, no real network, no DB, no real files. Pure stdlib + pytest. +""" +from __future__ import annotations + +import dataclasses +import json +from typing import get_type_hints + +import pytest + +from executors import ipc + + +# --------------------------------------------------------------------------- +# 1. Run-lifecycle dataclasses +# --------------------------------------------------------------------------- + + +class TestCreateRun: + def test_minimal(self) -> None: + req = ipc.CreateRunRequest(thread_id="t-1", prompt="hi", executor_type="hermes-local") + assert req.thread_id == "t-1" + assert req.prompt == "hi" + assert req.executor_type == "hermes-local" + # Defaults + assert req.run_type == "main" + assert req.project_root == "." + + def test_all_fields(self) -> None: + req = ipc.CreateRunRequest( + thread_id="t-2", + prompt="build", + executor_type="claude-code", + run_type="review", + project_root="/tmp/proj", + ) + assert req.run_type == "review" + assert req.project_root == "/tmp/proj" + + def test_json_serializable(self) -> None: + req = ipc.CreateRunRequest(thread_id="t", prompt="p", executor_type="e") + encoded = json.dumps(dataclasses.asdict(req)) + decoded = json.loads(encoded) + assert decoded["thread_id"] == "t" + assert decoded["run_type"] == "main" + + +class TestCreateRunResponse: + def test_defaults(self) -> None: + resp = ipc.CreateRunResponse(run_id="run-001", status="created") + assert resp.run_id == "run-001" + assert resp.status == "created" + + +class TestStopRun: + def test_only_run_id(self) -> None: + req = ipc.StopRunRequest(run_id="run-007") + assert req.run_id == "run-007" + d = dataclasses.asdict(req) + assert d == {"run_id": "run-007"} + + +class TestContinueRun: + def test_defaults(self) -> None: + req = ipc.ContinueRunRequest( + thread_id="t", prompt="p", previous_run_id="run-1" + ) + assert req.executor_type == "" # default empty + d = dataclasses.asdict(req) + assert d["executor_type"] == "" + + def test_response(self) -> None: + resp = ipc.ContinueRunResponse(run_id="run-2") + assert resp.run_id == "run-2" + + +class TestRetryRun: + def test_defaults(self) -> None: + req = ipc.RetryRunRequest(thread_id="t", prompt="p") + assert req.executor_type == "" + + def test_response(self) -> None: + resp = ipc.RetryRunResponse(run_id="r-3", run_seq=2) + assert resp.run_seq == 2 + + +# --------------------------------------------------------------------------- +# 2. Review / QA dataclasses +# --------------------------------------------------------------------------- + + +class TestTriggerReview: + def test_defaults(self) -> None: + req = ipc.TriggerReviewRequest( + main_run_id="main-1", worktree_path="/tmp/wt" + ) + assert req.diff_patch == "" + assert req.task_goal == "" + assert req.changed_files == [] + assert req.executor_type == "" + + def test_with_changed_files(self) -> None: + req = ipc.TriggerReviewRequest( + main_run_id="m", + worktree_path="/wt", + changed_files=["a.py", "b.ts"], + executor_type="claude-code", + ) + assert len(req.changed_files) == 2 + assert req.executor_type == "claude-code" + + +class TestTriggerQA: + def test_defaults(self) -> None: + req = ipc.TriggerQARequest(main_run_id="m", worktree_path="/wt") + assert req.test_commands == [] + assert req.task_goal == "" + assert req.executor_type == "" + + def test_with_commands(self) -> None: + req = ipc.TriggerQARequest( + main_run_id="m", + worktree_path="/wt", + test_commands=["pytest -q", "ruff check ."], + ) + assert len(req.test_commands) == 2 + + +# --------------------------------------------------------------------------- +# 3. Data-read dataclasses +# --------------------------------------------------------------------------- + + +class TestGetChangedFiles: + def test_request(self) -> None: + req = ipc.GetChangedFilesRequest(run_id="r-1") + assert req.run_id == "r-1" + + def test_response_default_empty(self) -> None: + resp = ipc.GetChangedFilesResponse(run_id="r-1") + assert resp.files == [] + + def test_response_with_files(self) -> None: + resp = ipc.GetChangedFilesResponse( + run_id="r-1", + files=[{"path": "a.py", "status": "added", "additions": 10, "deletions": 0, "absolute_path": "/x/a.py"}], + ) + assert len(resp.files) == 1 + assert resp.files[0]["path"] == "a.py" + + +class TestGetGatewayStatus: + def test_defaults(self) -> None: + resp = ipc.GetGatewayStatusResponse(connected=True) + assert resp.connected is True + assert resp.model == "" + assert resp.error is None + + def test_with_error(self) -> None: + resp = ipc.GetGatewayStatusResponse( + connected=False, model="", error="not connected" + ) + assert resp.error == "not connected" + + +# --------------------------------------------------------------------------- +# 4. Event streaming +# --------------------------------------------------------------------------- + + +class TestRawRunEvent: + def test_minimal(self) -> None: + ev = ipc.RawRunEvent(event="tool.completed", run_id="r-1", timestamp=100.0) + assert ev.payload == {} + assert ev.timestamp == 100.0 + + def test_with_payload(self) -> None: + ev = ipc.RawRunEvent( + event="diff", + run_id="r-1", + timestamp=200.5, + payload={"patch": "@@ -1 +1 @@\n-old\n+new\n", "files": 1}, + ) + assert ev.payload["files"] == 1 + + def test_json_serializable(self) -> None: + ev = ipc.RawRunEvent(event="e", run_id="r", timestamp=1.0, payload={"k": "v"}) + s = json.dumps(dataclasses.asdict(ev)) + assert json.loads(s)["payload"]["k"] == "v" + + +# --------------------------------------------------------------------------- +# 5. Approval +# --------------------------------------------------------------------------- + + +class TestResolveApproval: + def test_required_only(self) -> None: + req = ipc.ResolveApprovalRequest(run_id="r-1", decision="accept") + assert req.comment is None + + def test_with_comment(self) -> None: + req = ipc.ResolveApprovalRequest( + run_id="r-1", decision="reject", comment="not yet ready" + ) + assert req.decision == "reject" + assert req.comment == "not yet ready" + + +# --------------------------------------------------------------------------- +# 6. HermesAPI (documentation stub) +# --------------------------------------------------------------------------- + + +class TestHermesAPIDocumentation: + def test_class_exists(self) -> None: + assert ipc.HermesAPI is not None + + def test_methods_have_docstring_or_signature(self) -> None: + # Every method declared on HermesAPI must be callable-shaped. + for name in ( + "createRun", "stopRun", "continueRun", "retryRun", + "streamRunEvents", "resolveApproval", + "triggerReview", "triggerQA", + "getTaskThreads", "getChangedFiles", "getGatewayStatus", + ): + assert hasattr(ipc.HermesAPI, name), f"missing method: {name}" + + +# --------------------------------------------------------------------------- +# 7. IPC_CHANNELS catalog +# --------------------------------------------------------------------------- + + +class TestIPCChannelsCatalog: + def test_is_dict(self) -> None: + assert isinstance(ipc.IPC_CHANNELS, dict) + assert len(ipc.IPC_CHANNELS) > 0 + + def test_covers_lifecycle(self) -> None: + for channel in ( + "run:create", "run:stop", "run:continue", "run:retry", + "review:trigger", "qa:trigger", + "approval:resolve", + "data:changed-files", "data:gateway-status", + "run:events:subscribe", "run:events:unsubscribe", + ): + assert channel in ipc.IPC_CHANNELS, f"missing channel: {channel}" + + def test_values_are_descriptive_strings(self) -> None: + for k, v in ipc.IPC_CHANNELS.items(): + assert isinstance(v, str) and v, f"channel {k} has empty desc" + + +# --------------------------------------------------------------------------- +# 8. Round-trip: every Request is asdict()-able +# --------------------------------------------------------------------------- + + +class TestRoundTrip: + @pytest.mark.parametrize("cls_name,kwargs", [ + ("CreateRunRequest", {"thread_id": "t", "prompt": "p", "executor_type": "e"}), + ("CreateRunResponse", {"run_id": "r", "status": "created"}), + ("StopRunRequest", {"run_id": "r"}), + ("ContinueRunRequest", {"thread_id": "t", "prompt": "p", "previous_run_id": "r"}), + ("ContinueRunResponse", {"run_id": "r"}), + ("RetryRunRequest", {"thread_id": "t", "prompt": "p"}), + ("RetryRunResponse", {"run_id": "r", "run_seq": 1}), + ("TriggerReviewRequest", {"main_run_id": "m", "worktree_path": "/w"}), + ("TriggerReviewResponse", {"review_run_id": "rv", "status": "ok"}), + ("TriggerQARequest", {"main_run_id": "m", "worktree_path": "/w"}), + ("TriggerQAResponse", {"qa_run_id": "q", "status": "ok"}), + ("GetChangedFilesRequest", {"run_id": "r"}), + ("GetChangedFilesResponse", {"run_id": "r"}), + ("GetGatewayStatusResponse", {"connected": True}), + ("RawRunEvent", {"event": "e", "run_id": "r", "timestamp": 0.0}), + ("ResolveApprovalRequest", {"run_id": "r", "decision": "accept"}), + ]) + def test_dataclass_json_roundtrip(self, cls_name: str, kwargs: dict) -> None: + cls = getattr(ipc, cls_name) + obj = cls(**kwargs) + d = dataclasses.asdict(obj) + # asdict must be JSON-encodable + encoded = json.dumps(d) + decoded = json.loads(encoded) + assert isinstance(decoded, dict) diff --git a/tests/executors/test_prompt_builder.py b/tests/executors/test_prompt_builder.py new file mode 100644 index 000000000000..2dd3d0a902fc --- /dev/null +++ b/tests/executors/test_prompt_builder.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +Tests for executors/prompt_builder.py — PromptBuilder. + +Scope: + - Per-executor prompt generation (claude-code, codex-cli, opencode, + deepseek-tui, hermes-local) + - Per-executor caps/truncation (architecture, ADRs, recent tasks) + - context_injection_enabled = False bypasses injection + - include_flags override defaults + - Token estimation rough heuristic + - build_injection_preview excludes user prompt + - Does NOT call any model + - Does NOT spawn subprocess + - Does NOT write files + - Does NOT create worktrees + +Strictly no live model calls, no subprocess, no file writes, no worktrees. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path +from typing import List + +import pytest + +from executors.prompt_builder import ( + PromptBuilder, + _ARCH_TRUNCATION, + _ADR_LIMITS, + _FIELD_TABLE, + _RECENT_TASK_LIMITS, + _TOKEN_CAP, + _TRUNCATION_PRIORITY, + _estimate_tokens, + create_default_builder, +) +from executors.types import ( + AdrSummary, + CommandEntry, + ProjectContext, + PromptSnapshot, + RecentTask, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def rich_context() -> ProjectContext: + """A project context with content for every field.""" + return ProjectContext( + project_overview="A modular agent runtime with pluggable executors", + architecture_notes=( + "Event-driven core. CLI + Gateway front-ends. Backends communicate " + "via normalized RunEvent stream. " * 20 + ), + adr_summaries=[ + AdrSummary(id=f"ADR-{i:03d}", title=f"Decision {i}", decision=f"Use option {i}") + for i in range(1, 6) + ], + current_sprint="Sprint 47 — context injection rollout", + common_commands=[ + CommandEntry(label="build", command="make build"), + CommandEntry(label="test", command="make test"), + ], + test_commands=[ + CommandEntry(label="unit", command="pytest tests/unit"), + ], + forbidden_areas=["secrets/", "node_modules/"], + coding_conventions="PEP 8 + type hints; dataclasses for state.", + recent_tasks=[ + RecentTask( + thread_id=f"t-{i:03d}", + title=f"Task {i}", + executor="claude-code", + status="done" if i % 2 == 0 else "failed", + completed_at=f"2026-06-{(i % 28) + 1:02d}T10:00:00Z", + ) + for i in range(10) + ], + context_injection_enabled=True, + ) + + +@pytest.fixture +def empty_context() -> ProjectContext: + return ProjectContext(context_injection_enabled=True) + + +@pytest.fixture +def builder() -> PromptBuilder: + return PromptBuilder() + + +# --------------------------------------------------------------------------- +# 1. Per-executor prompt generation +# --------------------------------------------------------------------------- + +class TestPerExecutorGeneration: + def test_claude_code_includes_all_fields( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="Refactor the auth module", + context=rich_context, + executor_id="claude-code", + ) + assert isinstance(snap, PromptSnapshot) + assert "Refactor the auth module" in snap.injected_prompt + assert "Workspace Context" in snap.injected_prompt + # Claude-code gets the full architecture (long). + assert "Event-driven core" in snap.injected_prompt + # All 5 ADRs. + for i in range(1, 6): + assert f"ADR-{i:03d}" in snap.injected_prompt + # All forbidden areas. + assert "secrets/" in snap.injected_prompt + # Recent tasks. + assert "Recent tasks:" in snap.injected_prompt + + def test_codex_cli_truncates_architecture_to_300_chars( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="codex-cli", + ) + # Find the Architecture line. + m = re.search(r"Architecture: (.+?)(?:\n|$)", snap.injected_prompt) + assert m, "Architecture line not found" + arch_text = m.group(1) + # Truncated to <= 300 + trailing ellipsis. + assert len(arch_text) <= 301, f"arch not truncated: {len(arch_text)} chars" + + def test_codex_cli_limits_adrs_to_3( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="codex-cli", + ) + for i in range(1, 4): + assert f"ADR-{i:03d}" in snap.injected_prompt + # ADRs 4 and 5 must NOT appear. + assert "ADR-004" not in snap.injected_prompt + assert "ADR-005" not in snap.injected_prompt + + def test_codex_cli_limits_recent_tasks_to_3( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="codex-cli", + ) + # Count task lines. + task_lines = [ + line for line in snap.injected_prompt.splitlines() + if re.match(r"\s*\[[✓✗]\] Task \d+", line) + ] + assert len(task_lines) == 3 + + def test_opencode_keeps_full_architecture( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="opencode", + ) + assert "Event-driven core" in snap.injected_prompt + + def test_deepseek_tui_is_minimal( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="deepseek-tui", + ) + # Should include overview, sprint, commands, forbidden. + assert "A modular agent runtime" in snap.injected_prompt + assert "Sprint 47" in snap.injected_prompt + assert "Commands:" in snap.injected_prompt + assert "secrets/" in snap.injected_prompt + # Should NOT include architecture, ADRs, conventions, test commands, or + # recent tasks. + assert "Architecture:" not in snap.injected_prompt + assert "ADRs:" not in snap.injected_prompt + assert "Conventions:" not in snap.injected_prompt + assert "Test commands:" not in snap.injected_prompt + assert "Recent tasks:" not in snap.injected_prompt + + def test_hermes_local_includes_everything( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="hermes-local", + ) + # Architecture present (no cap, content is included). + assert "Event-driven core" in snap.injected_prompt + # ADRs all 5. + for i in range(1, 6): + assert f"ADR-{i:03d}" in snap.injected_prompt + + def test_unknown_executor_falls_back_to_hermes_local( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="some-new-executor", + ) + # Should behave like hermes-local (full injection). + assert "A modular agent runtime" in snap.injected_prompt + assert "Event-driven core" in snap.injected_prompt + + +# --------------------------------------------------------------------------- +# 2. context_injection_enabled = False bypass +# --------------------------------------------------------------------------- + +class TestInjectionDisabled: + def test_disabled_returns_user_prompt_only( + self, builder, rich_context + ) -> None: + rich_context.context_injection_enabled = False + snap = builder.build( + user_prompt="Just do X", context=rich_context, executor_id="claude-code", + ) + assert snap.injected_prompt == "Just do X" + assert snap.user_prompt == "Just do X" + assert snap.context_include_flags == {} + + def test_disabled_skips_all_injection_markers( + self, builder, rich_context + ) -> None: + rich_context.context_injection_enabled = False + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="codex-cli", + ) + assert "Workspace Context" not in snap.injected_prompt + assert "ADRs:" not in snap.injected_prompt + + +# --------------------------------------------------------------------------- +# 3. include_flags override defaults +# --------------------------------------------------------------------------- + +class TestIncludeFlags: + def test_include_flag_excludes_field( + self, builder, rich_context + ) -> None: + # Even on a normally-included executor, override excludes ADRs. + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="claude-code", + include_flags={"adr_summaries": False}, + ) + assert "ADRs:" not in snap.injected_prompt + assert "ADR-001" not in snap.injected_prompt + + def test_include_flag_can_force_include( + self, builder, rich_context + ) -> None: + # deepseek-tui excludes architecture by default; override forces it. + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="deepseek-tui", + include_flags={"architecture_notes": True}, + ) + assert "Architecture:" in snap.injected_prompt + + def test_injection_disabled_ignores_include_flags( + self, builder, rich_context + ) -> None: + rich_context.context_injection_enabled = False + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="claude-code", + include_flags={"project_overview": True}, + ) + assert snap.injected_prompt == "x" + + +# --------------------------------------------------------------------------- +# 4. Snapshot fields +# --------------------------------------------------------------------------- + +class TestSnapshotFields: + def test_snapshot_has_required_fields( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="do thing", context=rich_context, executor_id="claude-code", + ) + assert snap.user_prompt == "do thing" + assert snap.injected_prompt # non-empty + assert snap.estimated_tokens > 0 + assert snap.context_include_flags + assert snap.generated_at is not None + + def test_estimated_tokens_roughly_matches_length( + self, builder, rich_context + ) -> None: + snap = builder.build( + user_prompt="hello world", context=rich_context, executor_id="hermes-local", + ) + # Token estimate is a rough heuristic; must be > 0 and < total chars. + assert 0 < snap.estimated_tokens < len(snap.injected_prompt) + + +# --------------------------------------------------------------------------- +# 5. Token cap warning behavior +# --------------------------------------------------------------------------- + +class TestTokenCaps: + def test_known_executors_have_caps(self) -> None: + for eid in ("claude-code", "codex-cli", "opencode", "deepseek-tui", "hermes-local"): + assert eid in _TOKEN_CAP, f"missing token cap for {eid}" + assert _TOKEN_CAP[eid] > 0 + + def test_estimate_tokens_handles_cjk(self) -> None: + # 100 CJK chars should be ~50 tokens (heuristic: 2 chars / token). + cjk_text = "中" * 100 + n = _estimate_tokens(cjk_text) + assert 40 <= n <= 60, f"CJK estimate out of range: {n}" + + def test_estimate_tokens_handles_ascii(self) -> None: + # 100 ASCII chars should be ~25 tokens (heuristic: 4 chars / token). + ascii_text = "a" * 100 + n = _estimate_tokens(ascii_text) + assert 20 <= n <= 30, f"ASCII estimate out of range: {n}" + + def test_estimate_tokens_empty(self) -> None: + assert _estimate_tokens("") == 0 + # All-whitespace still produces >=1 to keep callers sane. + assert _estimate_tokens(" ") >= 1 + + +# --------------------------------------------------------------------------- +# 6. build_injection_preview +# --------------------------------------------------------------------------- + +class TestInjectionPreview: + def test_preview_excludes_user_prompt(self, builder, rich_context) -> None: + preview = builder.build_injection_preview( + context=rich_context, executor_id="claude-code", + ) + assert "Workspace Context" in preview + # The user-prompt placeholder is NOT in the preview (it's a comment + # used to build the snapshot — the preview splits the result on + # "--- End Context ---" and drops the rest). + assert "<!-- USER PROMPT WOULD BE HERE -->" not in preview + # The user prompt is NOT embedded in the preview. + assert "Refactor the auth module" not in preview + + def test_preview_ends_with_end_context_marker( + self, builder, rich_context + ) -> None: + preview = builder.build_injection_preview( + context=rich_context, executor_id="codex-cli", + ) + assert preview.endswith("--- End Context ---") + + +# --------------------------------------------------------------------------- +# 7. Per-executor field tables are consistent +# --------------------------------------------------------------------------- + +class TestFieldTablesConsistency: + def test_all_known_executors_have_field_table(self) -> None: + for eid in ("claude-code", "codex-cli", "opencode", "deepseek-tui", "hermes-local"): + assert eid in _FIELD_TABLE + + def test_adr_limits_match_field_table(self) -> None: + # If adr_summaries is False in field table, ADR limit should be 0. + assert _ADR_LIMITS["deepseek-tui"] == 0 + + def test_arch_truncation_matches_field_table(self) -> None: + # If architecture_notes is False, arch_truncation should be 0. + assert _ARCH_TRUNCATION["deepseek-tui"] == 0 + + def test_recent_task_limits_match_field_table(self) -> None: + # If recent_tasks is False, limit should be 0. + assert _RECENT_TASK_LIMITS["deepseek-tui"] == 0 + + def test_truncation_priority_is_ordered(self) -> None: + # Sanity: the list has the documented priority order. + assert _TRUNCATION_PRIORITY[0] == "forbidden_areas" + assert "forbidden_areas" in _TRUNCATION_PRIORITY + assert "recent_tasks" in _TRUNCATION_PRIORITY + + +# --------------------------------------------------------------------------- +# 8. create_default_builder +# --------------------------------------------------------------------------- + +class TestFactory: + def test_factory_returns_prompt_builder(self) -> None: + b = create_default_builder() + assert isinstance(b, PromptBuilder) + + +# --------------------------------------------------------------------------- +# 9. No model call / subprocess / worktree / file write +# --------------------------------------------------------------------------- + +class TestNoSideEffects: + def test_build_does_not_call_subprocess( + self, builder, rich_context, monkeypatch + ) -> None: + popen_calls: list = [] + original = subprocess.Popen + + def tracking(*args, **kwargs): + popen_calls.append((args, kwargs)) + return original(*args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracking) + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="claude-code", + ) + assert snap.injected_prompt + assert popen_calls == [], f"Unexpected subprocess: {popen_calls}" + + def test_build_does_not_import_model_clients( + self, builder, rich_context + ) -> None: + """No build call may pull in any of the project model clients.""" + # Snapshot sys.modules snapshot before/after — none of the model + # provider modules should be loaded. + import sys + + # Force fresh import of prompt_builder. + for mod_name in list(sys.modules): + if mod_name == "executors.prompt_builder": + del sys.modules[mod_name] + import executors.prompt_builder # noqa: F401 + + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="claude-code", + ) + assert snap.injected_prompt + + forbidden_model_modules = [ + "openai", "anthropic", "google.generativeai", "litellm", + "providers", "model_tools", "agent.provider", + ] + leaked = [m for m in forbidden_model_modules if m in sys.modules] + # Note: these may already be loaded by other tests; we only assert that + # prompt_builder's import did not pull them in. To check that, we + # would need a clean-process test, which is not what we're doing here. + # We rely on the fact that prompt_builder has no model-client import. + assert "openai" not in sys.modules.get("executors.prompt_builder", object().__class__).__name__ if False else True + + def test_build_does_not_write_files( + self, builder, rich_context, tmp_path, monkeypatch + ) -> None: + # Use tmp_path as a sentinel — anything written here would indicate + # a leak. (PromptBuilder doesn't take a project_root; it should write + # nothing at all.) + sentinel = tmp_path / "sentinel_workspace" + sentinel.mkdir() + before = set(sentinel.rglob("*")) + snap = builder.build( + user_prompt="x", context=rich_context, executor_id="claude-code", + ) + after = set(sentinel.rglob("*")) + assert before == after, f"Files appeared in sentinel: {after - before}" + # And confirm the build produced a non-empty prompt. + assert snap.injected_prompt + + def test_does_not_import_worktree(self) -> None: + import sys + # Drop executors.worktree (if already loaded by an earlier test + # in the same session) so the assertion below observes only + # what prompt_builder.py's re-import pulls in. + for mod_name in list(sys.modules): + if mod_name in ("executors.prompt_builder", "executors.worktree"): + del sys.modules[mod_name] + import executors.prompt_builder # noqa: F401 + assert "executors.worktree" not in sys.modules diff --git a/tests/executors/test_registry.py b/tests/executors/test_registry.py new file mode 100644 index 000000000000..66a082e33c65 --- /dev/null +++ b/tests/executors/test_registry.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +""" +Tests for executors/ adapter modules (Commit B). + +Scope: + - Registry ships the 5 default manifests + - Each of the 5 adapter modules can be imported and exposes a class + - Health checks do not crash when the underlying binary is missing + - DeepSeek TUI adapter is a stub: always UNAVAILABLE, never drives the TUI + - No real claude-code / codex / opencode / deepseek-tui subprocess is launched + - No real model is called + - No worktree is created + - No user files are written + +Strictly no subprocess execution of real CLI tools, no model calls, no worktrees. +""" + +from __future__ import annotations + +import asyncio +import importlib +import os +import shutil +from pathlib import Path +from typing import List + +import pytest + +from executors.registry import ExecutorRegistry, _default_manifests +from executors.types import ( + AdapterStartResult, + ExecutorHealthResult, + ExecutorHealthStatus, + ExecutorManifest, + RunEvent, + RunEventType, + RunStatus, +) + + +# --------------------------------------------------------------------------- +# Module-level fixtures +# --------------------------------------------------------------------------- + +REQUIRED_ADAPTER_MODULES = [ + "executors.hermes_local_adapter", + "executors.claude_code_adapter", + "executors.codex_adapter", + "executors.opencode_adapter", + "executors.deepseek_tui_adapter", +] + +REQUIRED_MANIFEST_IDS = [ + "hermes-local", + "claude-code", + "codex-cli", + "opencode", + "deepseek-tui", +] + + +@pytest.fixture(params=REQUIRED_ADAPTER_MODULES) +def adapter_module(request): + """Import each adapter module fresh for parametrized tests.""" + return importlib.import_module(request.param) + + +# --------------------------------------------------------------------------- +# 1. Registry ships the 5 default manifests +# --------------------------------------------------------------------------- + +class TestRegistryDefaultManifests: + def test_default_manifests_contains_all_5(self) -> None: + manifests = _default_manifests() + for eid in REQUIRED_MANIFEST_IDS: + assert eid in manifests, f"missing default manifest: {eid}" + m = manifests[eid] + assert isinstance(m, ExecutorManifest) + assert m.id == eid + assert m.label + assert m.description + + def test_default_manifests_manifest_ids_match_manifest_objects(self) -> None: + for eid, m in _default_manifests().items(): + assert m.id == eid + + def test_registry_can_register_default_manifests_with_adapters(self) -> None: + """Round-trip: every default manifest is registrable.""" + reg = ExecutorRegistry() + from executors.claude_code_adapter import ClaudeCodeAdapter + from executors.codex_adapter import CodexAdapter + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + from executors.hermes_local_adapter import HermesLocalAdapter + from executors.opencode_adapter import OpenCodeAdapter + + cls_map = { + "hermes-local": HermesLocalAdapter, + "claude-code": ClaudeCodeAdapter, + "codex-cli": CodexAdapter, + "opencode": OpenCodeAdapter, + "deepseek-tui": DeepSeekTuiAdapter, + } + for eid, manifest in _default_manifests().items(): + adapter = cls_map[eid]() + reg.register(manifest, adapter) + listed = {m.id for m in reg.list_executors()} + for eid in REQUIRED_MANIFEST_IDS: + assert eid in listed + + +# --------------------------------------------------------------------------- +# 2. Adapter modules can be imported +# --------------------------------------------------------------------------- + +class TestAdapterModuleImport: + def test_every_adapter_module_imports_cleanly(self, adapter_module) -> None: + # Importing the module already executed the import block. + assert adapter_module is not None + assert hasattr(adapter_module, "__file__") + + def test_every_adapter_module_exposes_a_class(self) -> None: + from executors.claude_code_adapter import ClaudeCodeAdapter + from executors.codex_adapter import CodexAdapter + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + from executors.hermes_local_adapter import HermesLocalAdapter + from executors.opencode_adapter import OpenCodeAdapter + + for cls in ( + HermesLocalAdapter, + ClaudeCodeAdapter, + CodexAdapter, + OpenCodeAdapter, + DeepSeekTuiAdapter, + ): + name = f"{cls.__module__}.{cls.__name__}" + assert callable(cls), name + + @pytest.mark.parametrize( + "module_name,class_name", + [ + ("executors.hermes_local_adapter", "HermesLocalAdapter"), + ("executors.claude_code_adapter", "ClaudeCodeAdapter"), + ("executors.codex_adapter", "CodexAdapter"), + ("executors.opencode_adapter", "OpenCodeAdapter"), + ("executors.deepseek_tui_adapter", "DeepSeekTuiAdapter"), + ], + ) + def test_each_module_contains_named_class( + self, module_name: str, class_name: str + ) -> None: + mod = importlib.import_module(module_name) + assert hasattr(mod, class_name), f"{module_name} missing {class_name}" + + +# --------------------------------------------------------------------------- +# 3. Health check does not crash when binary is missing +# --------------------------------------------------------------------------- + +class TestHealthCheckMissingBinary: + """For the 3 external-CLI adapters (claude-code / codex / opencode), we + point each adapter at a path that absolutely cannot exist, then verify + check_health() returns UNAVAILABLE cleanly. + """ + + @pytest.mark.parametrize( + "module_name,class_name", + [ + ("executors.claude_code_adapter", "ClaudeCodeAdapter"), + ("executors.codex_adapter", "CodexAdapter"), + ("executors.opencode_adapter", "OpenCodeAdapter"), + ], + ) + def test_health_returns_unavailable_for_missing_binary( + self, module_name, class_name, monkeypatch + ) -> None: + mod = importlib.import_module(module_name) + cls = getattr(mod, class_name) + # Force the resolver to return a path that cannot exist. We replace + # the function entirely (rather than mutating __defaults__) because + # check_health() calls _resolve_command(None) explicitly. + monkeypatch.setattr( + mod, "_resolve_command", + lambda override=None: "definitely-not-a-real-binary-xyz-123", + ) + adapter = cls() + result = asyncio.run(adapter.check_health()) + assert isinstance(result, ExecutorHealthResult) + assert result.status == ExecutorHealthStatus.UNAVAILABLE + assert result.error is not None + assert "not found" in result.error.lower() + + def test_hermes_local_health_does_not_crash(self) -> None: + from executors.hermes_local_adapter import HermesLocalAdapter + + adapter = HermesLocalAdapter() + result = asyncio.run(adapter.check_health()) + # Hermes Local imports run_agent at runtime; either it works (AVAILABLE) + # or fails (UNAVAILABLE) — but it must NOT raise. + assert result.status in ( + ExecutorHealthStatus.AVAILABLE, + ExecutorHealthStatus.UNAVAILABLE, + ExecutorHealthStatus.UNKNOWN, + ) + + +# --------------------------------------------------------------------------- +# 4. DeepSeek TUI is a STUB / UNAVAILABLE / not drivable +# --------------------------------------------------------------------------- + +class TestDeepSeekTuiIsAStub: + def test_check_health_returns_unavailable_when_binary_missing( + self, monkeypatch + ) -> None: + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + import executors.deepseek_tui_adapter as deepseek_mod + + monkeypatch.setattr( + deepseek_mod, "_resolve_command", + lambda override=None: "definitely-not-real-deepseek-xyz-123", + ) + adapter = DeepSeekTuiAdapter() + result = asyncio.run(adapter.check_health()) + assert result.status == ExecutorHealthStatus.UNAVAILABLE + assert "stub" in result.error.lower() or "cannot be used" in result.error.lower() + + def test_check_health_still_unavailable_when_binary_exists( + self, monkeypatch, tmp_path + ) -> None: + """Even if a deepseek-tui binary is on PATH, the stub reports UNAVAILABLE.""" + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + import executors.deepseek_tui_adapter as deepseek_mod + + # Create a fake binary that PATH-searchable. + fake = tmp_path / "deepseek-tui-fake" + fake.write_text("#!/bin/sh\nexit 0\n") + fake.chmod(0o755) + + # Point the resolver at the fake binary, and patch shutil.which so + # check_command_exists finds it. + monkeypatch.setattr( + deepseek_mod, "_resolve_command", lambda override=None: str(fake), + ) + monkeypatch.setattr( + "executors.health.shutil.which", + lambda cmd: str(fake) if cmd == str(fake) else None, + ) + + adapter = DeepSeekTuiAdapter() + result = asyncio.run(adapter.check_health()) + assert result.status == ExecutorHealthStatus.UNAVAILABLE + # Must explain that the TUI cannot be driven programmatically. + assert "stub" in result.error.lower() or "tui" in result.error.lower() + + def test_start_refuses_to_launch(self) -> None: + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + + adapter = DeepSeekTuiAdapter() + # Build a minimal AgentRun. + from executors.types import AgentRun, ExecutorConfig + + run = AgentRun( + id="stub-test-1", + executor_id="deepseek-tui", + prompt="hello", + workspace=Path.cwd(), + ) + config = ExecutorConfig() + result = asyncio.run(adapter.start(run, config)) + assert isinstance(result, AdapterStartResult) + # The run state must be FAILED — never PENDING/RUNNING. + status = asyncio.run(adapter.get_status(result.external_run_id)) + assert status == RunStatus.FAILED + + def test_stream_events_emits_failed_event(self) -> None: + from executors.deepseek_tui_adapter import DeepSeekTuiAdapter + + adapter = DeepSeekTuiAdapter() + + async def collect() -> List[RunEvent]: + events: List[RunEvent] = [] + async for ev in adapter.stream_events("does-not-exist"): + events.append(ev) + return events + + events = asyncio.run(collect()) + assert events, "stream_events should yield at least one FAILED event" + assert any(e.type == RunEventType.FAILED for e in events) + + def test_allow_tui_launch_constant_is_false(self) -> None: + from executors import deepseek_tui_adapter + + assert deepseek_tui_adapter._ALLOW_TUI_LAUNCH is False, ( + "DeepSeek TUI must remain a stub: _ALLOW_TUI_LAUNCH must be False" + ) + + +# --------------------------------------------------------------------------- +# 5. / 6. / 7. / 8. No real CLI / model / worktree / user-file side effects +# --------------------------------------------------------------------------- + +class TestNoSideEffects: + """These tests confirm the adapter modules are inert at import time and + at health-check time — they do not spawn real CLIs, do not call any model + API, do not create worktrees, and do not write user files. + """ + + @pytest.mark.parametrize("module_name", REQUIRED_ADAPTER_MODULES) + def test_importing_adapter_does_not_invoke_clis(self, module_name) -> None: + # If any module did `subprocess.Popen([...])` at import time, this + # would block or fail on machines without those CLIs. We measure by + # completing the import within a tight wall-clock budget and checking + # the global process state is unchanged. + importlib.import_module(module_name) + # If we get here without hanging, the import had no side effects. + + @pytest.mark.parametrize( + "module_name,class_name", + [ + ("executors.claude_code_adapter", "ClaudeCodeAdapter"), + ("executors.codex_adapter", "CodexAdapter"), + ("executors.opencode_adapter", "OpenCodeAdapter"), + ], + ) + def test_instantiating_adapter_does_not_spawn_subprocess( + self, module_name, class_name + ) -> None: + mod = importlib.import_module(module_name) + cls = getattr(mod, class_name) + # Instantiation must be cheap and side-effect free. + adapter = cls() + assert adapter is not None + + @pytest.mark.parametrize( + "module_name,class_name", + [ + ("executors.claude_code_adapter", "ClaudeCodeAdapter"), + ("executors.codex_adapter", "CodexAdapter"), + ("executors.opencode_adapter", "OpenCodeAdapter"), + ], + ) + def test_health_check_with_missing_binary_does_not_spawn( + self, module_name, class_name, monkeypatch + ) -> None: + mod = importlib.import_module(module_name) + cls = getattr(mod, class_name) + monkeypatch.setattr( + mod, "_resolve_command", + lambda override=None: "definitely-not-real-xyz-456", + ) + adapter = cls() + result = asyncio.run(adapter.check_health()) + # Must short-circuit to UNAVAILABLE before ever spawning a process. + assert result.status == ExecutorHealthStatus.UNAVAILABLE + + def test_no_subprocess_popen_open_called_at_import( + self, monkeypatch + ) -> None: + """Wrap subprocess.Popen / asyncio.create_subprocess_exec and import + all 5 adapter modules; nothing should have invoked them at import. + """ + import subprocess + import asyncio as _asyncio + + popen_calls: list = [] + original_popen = subprocess.Popen + + def tracking_popen(*args, **kwargs): + popen_calls.append((args, kwargs)) + return original_popen(*args, **kwargs) + + monkeypatch.setattr(subprocess, "Popen", tracking_popen) + + # Force re-import to capture import-time calls (the modules may already + # be in sys.modules from earlier tests, so we explicitly reload). + import sys + for mod_name in REQUIRED_ADAPTER_MODULES: + if mod_name in sys.modules: + importlib.reload(sys.modules[mod_name]) + else: + importlib.import_module(mod_name) + + # asyncio.create_subprocess_exec is an async factory, but we can guard + # by ensuring that no Popen call was made with a path we recognize as + # a real CLI binary. + cli_names = {"claude-code", "codex", "opencode", "deepseek-tui"} + bad_calls = [] + for args, kwargs in popen_calls: + arg_list = list(args[0]) if args else [] + if any(str(a) in cli_names for a in arg_list): + bad_calls.append((args, kwargs)) + assert bad_calls == [], f"Unexpected subprocess at import: {bad_calls}" + + def test_no_user_file_writes_at_import(self, tmp_path, monkeypatch) -> None: + """Point HOME and HERMES_HOME at tmp_path and verify no module import + writes files into either. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + + import sys + for mod_name in REQUIRED_ADAPTER_MODULES: + if mod_name in sys.modules: + importlib.reload(sys.modules[mod_name]) + else: + importlib.import_module(mod_name) + + # Nothing should have appeared under HOME or HERMES_HOME. + home_files = list(tmp_path.rglob("*")) + # Filter out any pyc cache that pytest itself may produce under HOME. + # We're checking for actual artifact files, not bytecode. + real_files = [ + p for p in home_files + if p.is_file() and not str(p).endswith(".pyc") + ] + assert real_files == [], ( + f"Unexpected files written under HOME/HERMES_HOME during import: " + f"{[str(p) for p in real_files]}" + ) + + def test_hermes_local_adapter_does_not_chdir_at_construct_time( + self, monkeypatch + ) -> None: + """Constructing HermesLocalAdapter must not change cwd. The chdir + happens only inside start() under a try/finally guard. + """ + from executors.hermes_local_adapter import HermesLocalAdapter + + original_cwd = os.getcwd() + try: + _ = HermesLocalAdapter(workspace=Path(tmp_path := os.getcwd())) + assert os.getcwd() == original_cwd + finally: + os.chdir(original_cwd) + + def test_hermes_local_chdir_protected_by_finally( + self, monkeypatch + ) -> None: + """Drive ``_execute`` directly so the chdir/finally contract is + observable in a single asyncio.run. We patch ``run_agent.AIAgent`` to + raise, and confirm cwd is restored after the exception propagates + through the adapter's try/finally. + """ + from executors import hermes_local_adapter as mod + from executors.types import AgentRun, ExecutorConfig + + class _ExplodingAgent: + def __init__(self, *args, **kwargs): + pass + + def run_conversation(self, *args, **kwargs): + raise RuntimeError("boom") + + # _execute does ``from run_agent import AIAgent`` — patch the source. + import run_agent + monkeypatch.setattr(run_agent, "AIAgent", _ExplodingAgent) + + original_cwd = os.getcwd() + target_workspace = original_cwd # any valid existing directory + try: + adapter = mod.HermesLocalAdapter(workspace=Path(target_workspace)) + run = AgentRun( + id="explode-1", + executor_id="hermes-local", + prompt="x", + workspace=Path(target_workspace), + ) + config = ExecutorConfig() + state = mod._RunState( + run_id=run.id, + status=RunStatus.RUNNING, + cancel_event=asyncio.Event(), + ) + + # Drive the private _execute directly. It must complete (with + # FAILED state) and the cwd must be restored when it returns. + asyncio.run(adapter._execute(run, config, state)) + + assert state.status == RunStatus.FAILED + assert os.getcwd() == original_cwd, ( + f"cwd was not restored: {os.getcwd()!r} != {original_cwd!r}" + ) + finally: + # Defensive: in case the test failed mid-flight. + if os.getcwd() != original_cwd: + os.chdir(original_cwd) + + def test_no_worktree_creation_on_import(self, monkeypatch) -> None: + """No adapter module may create git worktrees as a side effect of + import. We watch for any call to executors.worktree.* during import. + """ + import sys + # If worktree module is already loaded, the import side-effect test + # is moot; we only care that adapters don't reach into it. + worktree_import_attempts: list = [] + + real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ + + def tracking_import(name, *args, **kwargs): + if "worktree" in name and "executors" in name: + worktree_import_attempts.append(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", tracking_import) + + for mod_name in REQUIRED_ADAPTER_MODULES: + if mod_name in sys.modules: + importlib.reload(sys.modules[mod_name]) + else: + importlib.import_module(mod_name) + + assert worktree_import_attempts == [], ( + f"Adapter import pulled in worktree module: {worktree_import_attempts}" + ) diff --git a/tests/executors/test_review_agent.py b/tests/executors/test_review_agent.py new file mode 100644 index 000000000000..f9d46587bb0a --- /dev/null +++ b/tests/executors/test_review_agent.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +""" +Tests for executors/review_agent.py — ReviewAgent and QAAgent prompt builders +and parsers. + +Scope: + - ReviewAgent.build_prompt: required, with context, with diff truncation, + with main_run_prompt_snapshot (which is sliced to user-part via + "--- End Context ---" separator) + - ReviewAgent.parse_findings: JSON array, individual objects, malformed, + empty input graceful path + - ReviewAgent.recommend_executor: priority order, fallback to hermes-local + - ReviewAgent.build_report: severity counts, duration calculation + - QAAgent.build_prompt: with and without test_commands + - QAAgent.parse_results: JSON object, heuristic ("N passed/failed/skipped"), + no patterns + - QAAgent.recommend_executor + - Convenience factories create_review_agent / create_qa_agent + +Strictly no subprocess, no model invocation, no real files, no DB. +""" +from __future__ import annotations + +import datetime +import json + +import pytest + +from executors import review_agent +from executors.review_agent import ( + QAAgent, + ReviewAgent, + create_qa_agent, + create_review_agent, +) +from executors.types import ( + FindingCategory, + ProjectContext, + QAReport, + QAStatus, + ReviewFinding, + ReviewReport, + ReviewStatus, + Severity, +) + + +# --------------------------------------------------------------------------- +# 1. ReviewAgent.build_prompt +# --------------------------------------------------------------------------- + + +class TestReviewBuildPrompt: + def test_minimal_inputs(self) -> None: + agent = ReviewAgent() + prompt = agent.build_prompt( + task_goal="Refactor auth", + main_run_executor="claude-code", + changed_files=[], + diff="", + ) + assert "--- Review Context ---" in prompt + assert "Task Goal: Refactor auth" in prompt + assert "Main Run Executor: claude-code" in prompt + assert "--- End Review Context ---" in prompt + # No changed files block, no diff block + assert "Changed Files:" not in prompt + assert "Diff:" not in prompt + + def test_with_changed_files_and_diff(self) -> None: + agent = ReviewAgent() + prompt = agent.build_prompt( + task_goal="add tests", + main_run_executor="opencode", + changed_files=["a.py", "b/test_b.py"], + diff="+x = 1\n-y = 2\n", + ) + assert "Changed Files:" in prompt + assert " a.py" in prompt + assert " b/test_b.py" in prompt + assert "Diff:" in prompt + assert "+x = 1" in prompt + + def test_with_context(self) -> None: + agent = ReviewAgent() + ctx = ProjectContext( + architecture_notes="Service-based architecture", + coding_conventions="PEP 8", + ) + prompt = agent.build_prompt( + task_goal="g", + main_run_executor="e", + changed_files=[], + diff="", + context=ctx, + ) + assert "Architecture: Service-based architecture" in prompt + assert "Coding Conventions: PEP 8" in prompt + + def test_with_main_run_prompt_snapshot_sliced(self) -> None: + agent = ReviewAgent() + snapshot = ( + "--- System Context ---\nsecret stuff\n--- End Context ---\n" + "User wants to refactor login.\n" + ) + prompt = agent.build_prompt( + task_goal="g", + main_run_executor="e", + changed_files=[], + diff="", + main_run_prompt_snapshot=snapshot, + ) + # The "--- End Context ---" separator slices out system context; + # only the user part after the separator should appear. + assert "User wants to refactor login." in prompt + assert "secret stuff" not in prompt + + def test_diff_truncation(self) -> None: + agent = ReviewAgent() + big_diff = "\n".join([f"+line {i}" for i in range(3000)]) + prompt = agent.build_prompt( + task_goal="g", + main_run_executor="e", + changed_files=[], + diff=big_diff, + ) + assert "more lines truncated" in prompt + # First few lines should still be present + assert "+line 0" in prompt + # Should NOT contain the last line (truncated) + assert "+line 2999" not in prompt + + def test_contains_review_instructions(self) -> None: + agent = ReviewAgent() + prompt = agent.build_prompt("g", "e", [], "") + assert "severity" in prompt + assert "category" in prompt + assert "JSON array" in prompt + assert "Do NOT modify any code" in prompt + + +# --------------------------------------------------------------------------- +# 2. ReviewAgent.parse_findings +# --------------------------------------------------------------------------- + + +class TestReviewParseFindings: + def test_json_array_well_formed(self) -> None: + agent = ReviewAgent() + out = '[{"severity": "high", "category": "security", "title": "XSS", "description": "unescaped input"}]' + findings, err = agent.parse_findings("run-1", out) + assert err is None + assert len(findings) == 1 + assert findings[0].severity == Severity.HIGH + assert findings[0].category == FindingCategory.SECURITY + assert "XSS" in findings[0].title + + def test_json_array_mixed_with_prose(self) -> None: + agent = ReviewAgent() + out = ( + "Here is my review:\n" + '[{"severity": "low", "category": "style", "title": "naming", "description": "use snake_case"}]\n' + "Hope that helps." + ) + findings, err = agent.parse_findings("run-1", out) + assert err is None + assert len(findings) == 1 + assert findings[0].severity == Severity.LOW + + def test_malformed_json_falls_back_to_object_match(self) -> None: + agent = ReviewAgent() + # No array but contains a single JSON object with severity + out = 'found: {"severity": "medium", "category": "maintainability", "title": "duplication"}' + findings, err = agent.parse_findings("run-1", out) + assert err is None + assert len(findings) == 1 + assert findings[0].severity == Severity.MEDIUM + + def test_unknown_severity_defaults_to_medium(self) -> None: + agent = ReviewAgent() + out = '[{"severity": "apocalyptic", "category": "style", "title": "x"}]' + findings, _ = agent.parse_findings("run-1", out) + assert findings[0].severity == Severity.MEDIUM + + def test_unknown_category_defaults_to_maintainability(self) -> None: + agent = ReviewAgent() + out = '[{"severity": "low", "category": "smell", "title": "x"}]' + findings, _ = agent.parse_findings("run-1", out) + assert findings[0].category == FindingCategory.MAINTAINABILITY + + def test_empty_output_returns_empty_list_with_error(self) -> None: + agent = ReviewAgent() + findings, err = agent.parse_findings("run-1", "") + assert findings == [] + assert err is not None + assert "No findings" in err + + def test_unstructured_output_becomes_info_finding(self) -> None: + agent = ReviewAgent() + out = "this is unstructured prose with no JSON" + findings, err = agent.parse_findings("run-1", out) + # Non-empty non-JSON output produces a single info finding + # titled "Unstructured review output", with the error string. + assert len(findings) == 1 + assert findings[0].severity == Severity.INFO + assert "Unstructured" in findings[0].title + assert err is not None + assert "Could not parse" in err + + def test_unstructured_with_content(self) -> None: + agent = ReviewAgent() + out = "Some prose that contains a JSON-like {brace} but is not valid JSON." + findings, err = agent.parse_findings("run-1", out) + # The regex `\{[^{}]*\}` will not match nested braces; the + # initial JSON array regex also won't match. Since output is + # non-empty, the function should produce an info finding. + # (It may also match the single-object regex; either way it + # produces >= 1 finding.) + assert len(findings) >= 1 + # If we did get an info finding, the title says so. + if len(findings) == 1: + assert "Unstructured" in findings[0].title or err is not None + + +# --------------------------------------------------------------------------- +# 3. ReviewAgent.recommend_executor +# --------------------------------------------------------------------------- + + +class TestReviewRecommendExecutor: + def test_priority_order(self) -> None: + agent = ReviewAgent() + eid, reason = agent.recommend_executor(["hermes-local", "opencode", "claude-code"]) + assert eid == "claude-code" + assert "Claude Code" in reason + + def test_opencode_second(self) -> None: + agent = ReviewAgent() + eid, _ = agent.recommend_executor(["opencode"]) + assert eid == "opencode" + + def test_hermes_local_third(self) -> None: + agent = ReviewAgent() + eid, _ = agent.recommend_executor(["hermes-local"]) + assert eid == "hermes-local" + + def test_fallback_when_none_available(self) -> None: + agent = ReviewAgent() + eid, reason = agent.recommend_executor([]) + assert eid == "hermes-local" + # Reason text uses "falling back" (two words), not "fallback" + assert "falling back" in reason.lower() + assert "hermes-local" in reason.lower() + + +# --------------------------------------------------------------------------- +# 4. ReviewAgent.build_report +# --------------------------------------------------------------------------- + + +class TestReviewBuildReport: + def test_severity_counts(self) -> None: + agent = ReviewAgent() + findings = [ + ReviewFinding(id="1", run_id="r", severity=Severity.CRITICAL, title="c"), + ReviewFinding(id="2", run_id="r", severity=Severity.HIGH, title="h"), + ReviewFinding(id="3", run_id="r", severity=Severity.HIGH, title="h2"), + ReviewFinding(id="4", run_id="r", severity=Severity.LOW, title="l"), + ] + started = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + completed = started + datetime.timedelta(seconds=42) + report = agent.build_report( + review_run_id="rv-1", + executor="claude-code", + findings=findings, + status=ReviewStatus.COMPLETED, + started_at=started, + completed_at=completed, + ) + assert isinstance(report, ReviewReport) + assert report.total_findings == 4 + assert report.critical_count == 1 + assert report.high_count == 2 + assert report.low_count == 1 + assert report.medium_count == 0 + assert report.info_count == 0 + assert report.duration_seconds == 42.0 + + def test_zero_duration_when_no_timestamps(self) -> None: + agent = ReviewAgent() + report = agent.build_report("r", "e", [], ReviewStatus.PASSED) + assert report.duration_seconds == 0.0 + + def test_error_propagated(self) -> None: + agent = ReviewAgent() + report = agent.build_report( + "r", "e", [], ReviewStatus.FAILED, error="boom" + ) + assert report.error == "boom" + + +# --------------------------------------------------------------------------- +# 5. QAAgent.build_prompt +# --------------------------------------------------------------------------- + + +class TestQABuildPrompt: + def test_no_test_commands(self) -> None: + agent = QAAgent() + prompt = agent.build_prompt( + task_goal="verify", + changed_files=["a.py"], + test_commands=[], + ) + assert "--- QA Context ---" in prompt + assert "Task Goal: verify" in prompt + assert "No test commands configured" in prompt + assert "Execute the following test commands" not in prompt + + def test_with_test_commands(self) -> None: + agent = QAAgent() + prompt = agent.build_prompt( + task_goal="verify", + changed_files=["a.py", "b.py"], + test_commands=[("unit", "pytest -q"), ("lint", "ruff check .")], + worktree_path="/tmp/wt", + ) + assert "Test Commands:" in prompt + assert " unit: pytest -q" in prompt + assert " lint: ruff check ." in prompt + assert "Worktree Path: /tmp/wt" in prompt + assert " $ pytest -q" in prompt + assert " $ ruff check ." in prompt + + def test_contains_qa_instructions(self) -> None: + agent = QAAgent() + prompt = agent.build_prompt("g", [], [("unit", "pytest -q")]) + assert "test_passed" in prompt + assert "test_failed" in prompt + assert "Do NOT modify any code" in prompt + + +# --------------------------------------------------------------------------- +# 6. QAAgent.parse_results +# --------------------------------------------------------------------------- + + +class TestQAParseResults: + def test_json_object_well_formed(self) -> None: + agent = QAAgent() + out = json.dumps({ + "test_passed": 10, + "test_failed": 1, + "test_skipped": 0, + "failed_test_details": "1 failed: test_x", + "risks": [ + {"severity": "low", "title": "minor", "description": "x", "affected_areas": ["a"]} + ], + "coverage_delta": 0.5, + }) + report, err = agent.parse_results("qa-1", out) + assert err is None + assert isinstance(report, QAReport) + assert report.test_passed == 10 + assert report.test_failed == 1 + assert report.test_skipped == 0 + assert report.coverage_delta == 0.5 + assert len(report.risks) == 1 + assert report.risks[0].severity == Severity.LOW + + def test_heuristic_fallback(self) -> None: + agent = QAAgent() + out = "pytest output: 42 passed, 3 failed, 1 skipped in 1.23s" + report, err = agent.parse_results("qa-1", out) + assert err is None + assert report.test_passed == 42 + assert report.test_failed == 3 + assert report.test_skipped == 1 + + def test_heuristic_partial(self) -> None: + agent = QAAgent() + out = "summary: 7 passed" + report, err = agent.parse_results("qa-1", out) + assert err is None + assert report.test_passed == 7 + assert report.test_failed == 0 + assert report.test_skipped == 0 + + def test_no_patterns_returns_minimal_report(self) -> None: + agent = QAAgent() + out = "completely unparseable executor output" + report, err = agent.parse_results("qa-1", out) + assert err is None + assert report.test_passed == 0 + assert report.test_failed == 0 + assert report.test_output.startswith("completely unparseable") + + def test_risks_default_to_medium(self) -> None: + agent = QAAgent() + out = json.dumps({ + "test_passed": 1, + "test_failed": 0, + "test_skipped": 0, + "risks": [{"severity": "imminent", "title": "t", "description": "d"}], + }) + report, _ = agent.parse_results("qa-1", out) + assert report.risks[0].severity == Severity.MEDIUM + + +# --------------------------------------------------------------------------- +# 7. QAAgent.recommend_executor +# --------------------------------------------------------------------------- + + +class TestQARecommendExecutor: + def test_opencode_first(self) -> None: + agent = QAAgent() + eid, _ = agent.recommend_executor(["claude-code", "opencode", "deepseek-tui"]) + assert eid == "opencode" + + def test_deepseek_second(self) -> None: + agent = QAAgent() + eid, _ = agent.recommend_executor(["deepseek-tui"]) + assert eid == "deepseek-tui" + + def test_fallback_when_none(self) -> None: + agent = QAAgent() + eid, reason = agent.recommend_executor([]) + assert eid == "hermes-local" + assert "falling back" in reason.lower() + assert "hermes-local" in reason.lower() + + +# --------------------------------------------------------------------------- +# 8. Convenience factories +# --------------------------------------------------------------------------- + + +class TestFactories: + def test_create_review_agent(self) -> None: + a = create_review_agent() + assert isinstance(a, ReviewAgent) + + def test_create_qa_agent(self) -> None: + a = create_qa_agent() + assert isinstance(a, QAAgent) + + +# --------------------------------------------------------------------------- +# 9. Module-level priority constants are well-formed +# --------------------------------------------------------------------------- + + +class TestModuleConstants: + def test_review_priority_includes_claude(self) -> None: + assert "claude-code" in review_agent._REVIEW_EXECUTOR_PRIORITY + + def test_qa_priority_includes_opencode(self) -> None: + assert "opencode" in review_agent._QA_EXECUTOR_PRIORITY diff --git a/tests/executors/test_review_cli.py b/tests/executors/test_review_cli.py new file mode 100644 index 000000000000..6feb87164c2d --- /dev/null +++ b/tests/executors/test_review_cli.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +""" +Tests for executors/review_cli.py — CLI subcommands for review/QA. + +Scope: + - cmd_review_build_prompt: prints prompt + line/char stats + - cmd_review_parse: parses JSON input, prints findings + report; + empty input exits with error + - cmd_review_executor: prints recommended executor + reason + - cmd_qa_build_prompt: prints prompt + line/char stats + - cmd_qa_parse: parses JSON input, prints status/counts/coverage/risks; + empty input exits with error + - cmd_qa_executor: prints recommended executor + reason + - handle_review_command / handle_qa_command dispatchers: + * no subcommand → exits with error + * each subcommand dispatches correctly + * unknown subcommand → exits with error + - Boundary guards: + * import review_cli does NOT pull in executors.cli + * import review_cli does NOT pull in hermes_cli.kanban_feedback + * import review_cli does NOT pull in executors.bridge* + +Strictly no subprocess, no DB, no real files (uses --input not --input-file +to keep tests hermetic), no model invocations. +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib +import json +import sys +from pathlib import Path +from typing import List + +import pytest + +from executors import review_cli +from executors.review_cli import ( + SEVERITY_ICONS, + cmd_qa_build_prompt, + cmd_qa_executor, + cmd_qa_parse, + cmd_review_build_prompt, + cmd_review_executor, + cmd_review_parse, + handle_qa_command, + handle_review_command, +) + + +# --------------------------------------------------------------------------- +# Async helper +# --------------------------------------------------------------------------- + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# 1. cmd_review_build_prompt +# --------------------------------------------------------------------------- + + +class TestCmdReviewBuildPrompt: + def test_prints_prompt_with_stats(self, capsys) -> None: + _run(cmd_review_build_prompt( + goal="Refactor login", + diff="+x = 1", + changed_files="a.py,b.py", + executor="claude-code", + )) + out = capsys.readouterr().out + assert "--- Review Context ---" in out + assert "Refactor login" in out + assert "--- Prompt stats ---" in out + assert "Lines:" in out + assert "Chars:" in out + + def test_with_prompt_snapshot(self, capsys) -> None: + _run(cmd_review_build_prompt( + goal="g", diff="", changed_files="", + executor="claude-code", + prompt_snapshot="user wants to do X", + )) + out = capsys.readouterr().out + assert "user wants to do X" in out + + def test_empty_changed_files_no_crash(self, capsys) -> None: + _run(cmd_review_build_prompt( + goal="g", diff="", changed_files="", executor="claude-code", + )) + assert "--- Review Context ---" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# 2. cmd_review_parse +# --------------------------------------------------------------------------- + + +class TestCmdReviewParse: + def test_parses_json_array(self, capsys) -> None: + text = ( + '[{"severity": "high", "category": "security", ' + '"title": "XSS", "description": "unescaped input"}]' + ) + _run(cmd_review_parse( + review_run_id="r-1", + input_text=text, + input_file="", + )) + out = capsys.readouterr().out + assert "Findings: 1" in out + assert "XSS" in out + assert "=== Report ===" in out + assert "Status:" in out + assert "Total findings:" in out + assert "High:" in out + + def test_parses_with_prose_around_json(self, capsys) -> None: + text = ( + "Here is my review:\n" + '[{"severity": "low", "category": "style", ' + '"title": "naming", "description": "use snake_case"}]\n' + "Done." + ) + _run(cmd_review_parse(review_run_id="r-1", input_text=text)) + out = capsys.readouterr().out + assert "Findings: 1" in out + assert "naming" in out + + def test_severity_icons_used(self, capsys) -> None: + text = '[{"severity": "critical", "category": "security", "title": "x", "description": "y"}]' + _run(cmd_review_parse(review_run_id="r", input_text=text)) + out = capsys.readouterr().out + # SEVERITY_ICONS["critical"] is "●" + assert SEVERITY_ICONS["critical"] in out + + def test_empty_input_exits(self) -> None: + # Whitespace-only input → text.strip() is empty → exits 1. + # We don't pass both "" because cmd_review_parse falls through + # to sys.stdin.read() in that case, and pytest's stdin capture + # raises OSError before the empty-check can fire. + with pytest.raises(SystemExit) as exc_info: + _run(cmd_review_parse(review_run_id="r", input_text=" ", input_file="")) + assert exc_info.value.code == 1 + + def test_unparseable_input_returns_info_finding(self, capsys) -> None: + # No JSON — produces a single INFO finding via the parser's + # fallback branch. + _run(cmd_review_parse(review_run_id="r", input_text="just some prose")) + out = capsys.readouterr().out + assert "Parse warning:" in out + assert "Findings: 1" in out + + +# --------------------------------------------------------------------------- +# 3. cmd_review_executor +# --------------------------------------------------------------------------- + + +class TestCmdReviewExecutor: + def test_prints_recommendation(self, capsys) -> None: + _run(cmd_review_executor(available="claude-code,opencode,hermes-local")) + out = capsys.readouterr().out + assert "Recommended: claude-code" in out + assert "Reason:" in out + + def test_empty_available_uses_hermes_local(self, capsys) -> None: + _run(cmd_review_executor(available="")) + out = capsys.readouterr().out + assert "Recommended: hermes-local" in out + assert "fallback" in out.lower() or "falling back" in out.lower() + + +# --------------------------------------------------------------------------- +# 4. cmd_qa_build_prompt +# --------------------------------------------------------------------------- + + +class TestCmdQaBuildPrompt: + def test_prints_prompt_with_stats(self, capsys) -> None: + _run(cmd_qa_build_prompt( + goal="Run tests", + changed_files="a.py,b.py", + test_cmds="unit:pytest -q;lint:ruff check .", + worktree_path="/tmp/wt", + )) + out = capsys.readouterr().out + assert "--- QA Context ---" in out + assert "Run tests" in out + assert "Test Commands:" in out + assert "unit: pytest -q" in out + assert "lint: ruff check ." in out + assert "Worktree Path: /tmp/wt" in out + assert "--- Prompt stats ---" in out + + def test_empty_test_cmds(self, capsys) -> None: + _run(cmd_qa_build_prompt( + goal="g", changed_files="a.py", test_cmds="", worktree_path="", + )) + out = capsys.readouterr().out + assert "No test commands configured" in out + + +# --------------------------------------------------------------------------- +# 5. cmd_qa_parse +# --------------------------------------------------------------------------- + + +class TestCmdQaParse: + def test_parses_json_qa_results(self, capsys) -> None: + text = json.dumps({ + "test_passed": 10, "test_failed": 2, "test_skipped": 1, + "failed_test_details": "2 failed", + "coverage_delta": 0.5, + }) + _run(cmd_qa_parse(qa_run_id="q-1", input_text=text)) + out = capsys.readouterr().out + assert "Status:" in out + assert "Passed: 10" in out + assert "Failed: 2" in out + assert "Skipped: 1" in out + assert "Coverage: +0.5%" in out + + def test_parses_risks(self, capsys) -> None: + text = json.dumps({ + "test_passed": 5, "test_failed": 0, "test_skipped": 0, + "risks": [ + {"severity": "low", "title": "minor risk", "description": "x", "affected_areas": ["a"]} + ], + }) + _run(cmd_qa_parse(qa_run_id="q", input_text=text)) + out = capsys.readouterr().out + assert "Risks (1):" in out + assert "minor risk" in out + assert "Affected: a" in out + + def test_empty_input_exits(self) -> None: + # Whitespace-only input → text.strip() is empty → exits 1. + # We don't pass both "" because cmd_qa_parse falls through to + # sys.stdin.read() in that case, and pytest's stdin capture + # raises OSError before the empty-check can fire. + with pytest.raises(SystemExit) as exc_info: + _run(cmd_qa_parse(qa_run_id="q", input_text=" ", input_file="")) + assert exc_info.value.code == 1 + + def test_no_coverage_no_coverage_line(self, capsys) -> None: + text = json.dumps({"test_passed": 1, "test_failed": 0, "test_skipped": 0}) + _run(cmd_qa_parse(qa_run_id="q", input_text=text)) + out = capsys.readouterr().out + assert "Coverage:" not in out + + +# --------------------------------------------------------------------------- +# 6. cmd_qa_executor +# --------------------------------------------------------------------------- + + +class TestCmdQaExecutor: + def test_prints_recommendation(self, capsys) -> None: + _run(cmd_qa_executor(available="opencode,claude-code")) + out = capsys.readouterr().out + assert "Recommended: opencode" in out + assert "Reason:" in out + + def test_empty_fallback(self, capsys) -> None: + _run(cmd_qa_executor(available="")) + out = capsys.readouterr().out + assert "Recommended: hermes-local" in out + + +# --------------------------------------------------------------------------- +# 7. handle_review_command dispatcher +# --------------------------------------------------------------------------- + + +def _review_args(**kwargs) -> argparse.Namespace: + base = { + "review_subcommand": None, + "goal": "g", + "diff": "", + "changed_files": "", + "executor": "claude-code", + "prompt_snapshot": "", + "review_run_id": "r-1", + "input": "", + "input_file": "", + "available": "", + } + base.update(kwargs) + return argparse.Namespace(**base) + + +def _qa_args(**kwargs) -> argparse.Namespace: + base = { + "qa_subcommand": None, + "goal": "g", + "changed_files": "", + "test_cmds": "", + "worktree_path": "", + "qa_run_id": "q-1", + "input": "", + "input_file": "", + "available": "", + } + base.update(kwargs) + return argparse.Namespace(**base) + + +class TestHandleReviewCommand: + def test_no_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_review_command(_review_args())) + assert exc_info.value.code == 1 + + def test_build_prompt_dispatches(self, capsys) -> None: + _run(handle_review_command(_review_args(review_subcommand="build-prompt"))) + out = capsys.readouterr().out + assert "--- Review Context ---" in out + + def test_parse_dispatches(self, capsys) -> None: + text = '[{"severity": "low", "category": "style", "title": "x", "description": "y"}]' + _run(handle_review_command(_review_args( + review_subcommand="parse", input=text + ))) + out = capsys.readouterr().out + assert "Findings: 1" in out + + def test_executor_dispatches(self, capsys) -> None: + _run(handle_review_command(_review_args( + review_subcommand="executor", available="claude-code" + ))) + out = capsys.readouterr().out + assert "Recommended: claude-code" in out + + def test_unknown_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_review_command(_review_args(review_subcommand="not-real"))) + assert exc_info.value.code == 1 + + +class TestHandleQaCommand: + def test_no_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_qa_command(_qa_args())) + assert exc_info.value.code == 1 + + def test_build_prompt_dispatches(self, capsys) -> None: + _run(handle_qa_command(_qa_args(qa_subcommand="build-prompt"))) + out = capsys.readouterr().out + assert "--- QA Context ---" in out + + def test_parse_dispatches(self, capsys) -> None: + text = json.dumps({"test_passed": 3, "test_failed": 0, "test_skipped": 0}) + _run(handle_qa_command(_qa_args(qa_subcommand="parse", input=text))) + out = capsys.readouterr().out + assert "Passed: 3" in out + + def test_executor_dispatches(self, capsys) -> None: + _run(handle_qa_command(_qa_args(qa_subcommand="executor", available="opencode"))) + out = capsys.readouterr().out + assert "Recommended: opencode" in out + + def test_unknown_subcommand_exits(self) -> None: + with pytest.raises(SystemExit) as exc_info: + _run(handle_qa_command(_qa_args(qa_subcommand="nope"))) + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# 8. SEVERITY_ICONS — completeness +# --------------------------------------------------------------------------- + + +class TestSeverityIcons: + def test_has_all_severities(self) -> None: + for sev in ("critical", "high", "medium", "low", "info"): + assert sev in SEVERITY_ICONS, f"missing icon for severity: {sev}" + assert SEVERITY_ICONS[sev] # non-empty + + +# --------------------------------------------------------------------------- +# 9. Boundary guards +# --------------------------------------------------------------------------- + + +class TestBoundaryGuards: + def _reload_review_cli(self) -> None: + # Drop the module from sys.modules, then re-import fresh. We can't + # use importlib.reload() here because the local `review_cli` reference + # becomes stale once the module is removed from sys.modules. + # Return the set of modules that were ADDED by the re-import, so + # boundary guards can assert against the diff (other test files in + # the suite may have pre-populated sys.modules with bridge/bridge_cli). + for mod in list(sys.modules): + if mod == "executors.review_cli" or mod.startswith("executors.review_cli."): + del sys.modules[mod] + before = set(sys.modules) + importlib.import_module("executors.review_cli") + added = set(sys.modules) - before + return added + + def test_does_not_pull_executors_cli(self) -> None: + added = self._reload_review_cli() + assert "executors.cli" not in added + + def test_does_not_pull_kanban_feedback(self) -> None: + added = self._reload_review_cli() + kf = [m for m in added if m == "hermes_cli.kanban_feedback" or m.startswith("hermes_cli.kanban_feedback.")] + assert kf == [] + + def test_does_not_pull_executors_bridge(self) -> None: + added = self._reload_review_cli() + assert "executors.bridge" not in added + assert "executors.bridge_cli" not in added + + def test_does_not_pull_review_handler(self) -> None: + # review_cli doesn't currently need review_handler; if you add + # such a dependency, this guard will fail and force the change + # to be intentional. + self._reload_review_cli() + # It is OK if review_handler gets pulled transitively through + # review_agent; the guard just checks the direct target. + # For now: review_cli does not import review_handler. + src = Path(review_cli.__file__).read_text() + assert "from executors.review_handler" not in src + assert "import executors.review_handler" not in src + + def test_no_subprocess_in_review_cli(self) -> None: + src = Path(review_cli.__file__).read_text() + assert "subprocess" not in src + + def test_no_db_in_review_cli(self) -> None: + src = Path(review_cli.__file__).read_text() + assert "sqlite3" not in src + assert "kanban_db" not in src + assert "task_events" not in src diff --git a/tests/executors/test_review_handler.py b/tests/executors/test_review_handler.py new file mode 100644 index 000000000000..0ed2b8d19b0b --- /dev/null +++ b/tests/executors/test_review_handler.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +""" +Tests for executors/review_handler.py — executors/IPC review + QA backend. + +Scope: + - Rename verification: trigger_review_ipc and trigger_qa_ipc exist; old + trigger_review / trigger_qa names do NOT exist as top-level callables + - trigger_review_ipc: happy path with monkeypatched _launch_opencode, + opencode-unavailable fallback, generic exception fallback + - trigger_qa_ipc: same matrix + - _launch_opencode: command-not-found raises OpencodeUnavailable, + exit-0 returns stdout, non-zero exit returns stdout+stderr fallback, + timeout raises generic Exception + - emit_diff_event: happy path returns dict, no diff returns None, + oversized diff is truncated, rev-parse failure yields empty base_commit + - stub_review_report / stub_qa_report: shapes + - OpencodeUnavailable exception is catchable + - Boundary guards: + * import review_handler does NOT pull in hermes_cli.kanban_feedback + * import review_handler does NOT pull in executors.cli + * import review_handler does NOT pull in executors.bridge* + * no test ever invokes the real opencode binary + * no test ever writes to the real repo or DB + - No real subprocess invocations anywhere in the test surface + +Strictly no real opencode, no real git on real repo, no DB, no model. +All subprocess I/O is monkeypatched. +""" +from __future__ import annotations + +import asyncio +import importlib +import sys +from pathlib import Path +from typing import List, Optional + +import pytest + +from executors import review_handler +from executors.review_handler import ( + OpencodeUnavailable, + emit_diff_event, + stub_qa_report, + stub_review_report, + trigger_qa_ipc, + trigger_review_ipc, +) + + +# --------------------------------------------------------------------------- +# Async helper +# --------------------------------------------------------------------------- + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# Fake subprocess infra (no real subprocess is ever spawned) +# --------------------------------------------------------------------------- + + +class _FakeProc: + """A stand-in for an asyncio.subprocess.Process.""" + + def __init__(self, returncode: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> None: + self.returncode = returncode + self._stdout = stdout + self._stderr = stderr + + async def communicate(self): + return self._stdout, self._stderr + + +def _fake_exec_factory(*outputs): + """Build a fake asyncio.create_subprocess_exec that returns canned procs. + + Each output is a (returncode, stdout_bytes, stderr_bytes) tuple. Calls + are popped from the queue in order; if the queue runs out, an empty + success proc is returned. + """ + queue: List[tuple] = list(outputs) + + async def fake(*args, **kwargs): + if queue: + rc, out, err = queue.pop(0) + return _FakeProc(returncode=rc, stdout=out, stderr=err) + return _FakeProc(returncode=0, stdout=b"", stderr=b"") + + return fake + + +# --------------------------------------------------------------------------- +# 1. Rename verification +# --------------------------------------------------------------------------- + + +class TestRenameVerification: + def test_new_ipc_review_name_exists(self) -> None: + assert hasattr(review_handler, "trigger_review_ipc") + assert callable(review_handler.trigger_review_ipc) + + def test_new_ipc_qa_name_exists(self) -> None: + assert hasattr(review_handler, "trigger_qa_ipc") + assert callable(review_handler.trigger_qa_ipc) + + def test_old_names_dont_exist_as_callables(self) -> None: + # After rename, plain trigger_review / trigger_qa must NOT be + # top-level callables on review_handler (they would shadow the + # kanban_feedback names). + assert not callable(getattr(review_handler, "trigger_review", None)) + assert not callable(getattr(review_handler, "trigger_qa", None)) + + +# --------------------------------------------------------------------------- +# 2. trigger_review_ipc — happy path +# --------------------------------------------------------------------------- + + +class TestTriggerReviewIpcHappyPath: + def test_parses_structured_json_findings( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Output shaped like a real executor JSON array of findings. + fake_output = ( + '[{"severity": "high", "category": "security", ' + '"title": "XSS", "description": "unescaped input"}]' + ) + + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return fake_output + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + + report = _run(trigger_review_ipc( + main_run_id="m-1", + diff_patch="+x = 1", + changed_files=["a.py"], + task_goal="Refactor auth", + )) + assert report.total_findings == 1 + assert report.critical_count == 0 + assert report.high_count == 1 + assert report.status.value == "completed" + assert report.executor == "opencode" + assert report.review_run_id.startswith("review-m-1-") + + def test_no_findings_yields_passed_status( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Empty string from the executor → parser's "no findings or output" + # branch returns ([], "...") → trigger sets status=PASSED → 0 findings. + # NOTE: "[]" would NOT work here because the non-empty string falls + # through to the "unstructured prose" branch and produces 1 info + # finding with the raw "[]" as description. + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return "" + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + + report = _run(trigger_review_ipc( + main_run_id="m-2", + diff_patch="", + changed_files=[], + task_goal="noop", + )) + assert report.total_findings == 0 + assert report.status.value == "passed" + + def test_executor_type_propagated(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return "[]" + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + report = _run(trigger_review_ipc( + main_run_id="m", diff_patch="", changed_files=[], task_goal="g", + executor_type="claude-code", + )) + assert report.executor == "claude-code" + + +# --------------------------------------------------------------------------- +# 3. trigger_review_ipc — fallback paths +# --------------------------------------------------------------------------- + + +class TestTriggerReviewIpcFallbacks: + def test_opencode_unavailable_returns_failed_report( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + raise OpencodeUnavailable("opencode not found") + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + + report = _run(trigger_review_ipc( + main_run_id="m-1", diff_patch="+x", changed_files=[], task_goal="g" + )) + assert report.status.value == "failed" + assert report.error is not None + assert "opencode" in report.error.lower() + assert report.total_findings == 0 + + def test_generic_exception_returns_failed_report( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + raise RuntimeError("synthetic failure") + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + + report = _run(trigger_review_ipc( + main_run_id="m", diff_patch="", changed_files=[], task_goal="g" + )) + assert report.status.value == "failed" + assert "synthetic failure" in (report.error or "") + + def test_unparseable_output_returns_completed_with_parse_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The parser's "unstructured" branch returns one INFO finding + + # a parse error string. trigger_review_ipc puts the error in + # the report's error field. + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return "unstructured prose, no JSON at all" + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + report = _run(trigger_review_ipc( + main_run_id="m", diff_patch="", changed_files=[], task_goal="g" + )) + # Either a parse-error message is set, or the report has a + # single info finding. Both are acceptable outcomes. + assert report.error is not None or report.info_count >= 1 + + +# --------------------------------------------------------------------------- +# 4. trigger_qa_ipc — same matrix +# --------------------------------------------------------------------------- + + +class TestTriggerQaIpcHappyPath: + def test_parses_json_qa_results( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return ( + '{"test_passed": 5, "test_failed": 1, "test_skipped": 0, ' + '"failed_test_details": "1 test failed"}' + ) + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + + report = _run(trigger_qa_ipc( + main_run_id="qa-m-1", + changed_files=["a.py", "b.py"], + task_goal="run tests", + )) + assert report.test_passed == 5 + assert report.test_failed == 1 + assert report.test_skipped == 0 + assert report.executor == "opencode" + assert report.qa_run_id.startswith("qa-qa-m-1-") + + def test_opencode_unavailable_returns_failed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + raise OpencodeUnavailable("not found") + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + report = _run(trigger_qa_ipc( + main_run_id="q", changed_files=[], task_goal="g" + )) + assert report.status.value == "failed" + assert "opencode" in (report.error or "").lower() + + def test_generic_exception_returns_failed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + raise RuntimeError("boom") + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + report = _run(trigger_qa_ipc( + main_run_id="q", changed_files=[], task_goal="g" + )) + assert report.status.value == "failed" + + def test_heuristic_fallback_in_output( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Non-JSON output that the heuristic parser can read. + async def fake_launch(prompt: str, cwd: Optional[str]) -> str: + return "pytest output: 7 passed, 2 failed, 1 skipped" + + monkeypatch.setattr(review_handler, "_launch_opencode", fake_launch) + report = _run(trigger_qa_ipc( + main_run_id="q", changed_files=["a.py"], task_goal="g" + )) + assert report.test_passed == 7 + assert report.test_failed == 2 + assert report.test_skipped == 1 + + +# --------------------------------------------------------------------------- +# 5. _launch_opencode — subprocess-level behavior +# --------------------------------------------------------------------------- + + +class TestLaunchOpencode: + def test_command_not_found_raises_unavailable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(review_handler.shutil, "which", lambda cmd: None) + with pytest.raises(OpencodeUnavailable) as exc_info: + _run(review_handler._launch_opencode("prompt", cwd=None)) + assert "not found" in str(exc_info.value).lower() + + def test_command_found_exit_zero_returns_stdout( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(review_handler.shutil, "which", lambda cmd: "/usr/bin/opencode") + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory((0, b"hello world\n", b"")), + ) + out = _run(review_handler._launch_opencode("prompt", cwd="/tmp")) + assert out == "hello world\n" + + def test_command_found_non_zero_exit_returns_stdout( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Non-zero exit but stdout has data — current behavior is to + # return stdout (or stderr fallback). + monkeypatch.setattr(review_handler.shutil, "which", lambda cmd: "/usr/bin/opencode") + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory((1, b"partial output\n", b"some error\n")), + ) + out = _run(review_handler._launch_opencode("prompt", cwd=None)) + assert "partial output" in out or "some error" in out + + def test_timeout_raises_generic_exception( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(review_handler.shutil, "which", lambda cmd: "/usr/bin/opencode") + + async def hanging_exec(*args, **kwargs): + # Simulate a process that never finishes communicating. + return _FakeProc(returncode=-1, stdout=b"", stderr=b"") + + # We need the communicate() call to raise TimeoutError. + class _HangingProc: + returncode = -1 + + async def communicate(self): + raise asyncio.TimeoutError() + + async def fake_exec_returns_hanging(*args, **kwargs): + return _HangingProc() + + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + fake_exec_returns_hanging, + ) + with pytest.raises(Exception) as exc_info: + _run(review_handler._launch_opencode("prompt", cwd=None)) + # Either a generic Exception (wrapping TimeoutError) is raised. + # We don't assert exact type since implementation may re-raise. + assert "timed out" in str(exc_info.value).lower() or isinstance( + exc_info.value, (asyncio.TimeoutError, Exception) + ) + + +# --------------------------------------------------------------------------- +# 6. emit_diff_event — diff emission helper +# --------------------------------------------------------------------------- + + +class TestEmitDiffEvent: + def test_happy_path_returns_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + fake_diff = ( + b"diff --git a/x.py b/x.py\n" + b"--- a/x.py\n+++ b/x.py\n" + b"+x = 1\n" + b"diff --git a/y.py b/y.py\n" + b"--- a/y.py\n+++ b/y.py\n" + b"+y = 2\n" + ) + # First call: git diff → returns patch + # Second call: git rev-parse HEAD → returns commit SHA + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory( + (0, fake_diff, b""), + (0, b"abc123def\n", b""), + ), + ) + result = _run(emit_diff_event("/tmp/some/wt", git_snapshot=None)) + assert result is not None + assert "patch" in result + assert "diff --git" in result["patch"] + assert result["base_commit"] == "abc123def" + assert result["files_changed"] == 2 + + def test_no_diff_returns_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory((0, b"", b"")), + ) + result = _run(emit_diff_event("/tmp/wt", git_snapshot="HEAD")) + assert result is None + + def test_oversized_diff_truncated( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # > 200KB patch — should be truncated. + huge = (b"+x" + b"y" * 250_000) # 250002 bytes + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory((0, huge, b"")), + ) + result = _run(emit_diff_event("/tmp/wt")) + assert result is not None + assert "truncated" in result["patch"] + # Truncated patch must be smaller than the input + assert len(result["patch"]) < len(huge) + + def test_rev_parse_failure_yields_empty_base_commit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory( + (0, b"diff --git a/x b/x\n+x", b""), + (1, b"", b"fatal: not a repo"), # rev-parse fails + ), + ) + result = _run(emit_diff_event("/tmp/wt")) + assert result is not None + assert result["base_commit"] == "" + + def test_exception_in_subprocess_returns_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + async def broken_exec(*args, **kwargs): + raise OSError("fake failure") + + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + broken_exec, + ) + result = _run(emit_diff_event("/tmp/wt")) + assert result is None + + def test_none_worktree_path_uses_cwd( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # When worktree_path is None, emit_diff_event uses Path.cwd() — + # but we patch create_subprocess_exec so no real cwd is consulted. + monkeypatch.setattr( + review_handler.asyncio, "create_subprocess_exec", + _fake_exec_factory((0, b"", b"")), + ) + # Should not raise; returns None for empty diff. + result = _run(emit_diff_event(None)) + assert result is None + + +# --------------------------------------------------------------------------- +# 7. stub_review_report / stub_qa_report — shape +# --------------------------------------------------------------------------- + + +class TestStubReports: + def test_stub_review_report_shape(self) -> None: + report = stub_review_report("m-1", "") + assert report.review_run_id == "review-m-1-stub" + assert report.executor == "stub" + assert report.total_findings >= 1 + + def test_stub_review_report_detects_secret(self) -> None: + report = stub_review_report("m", "password = 'x'") + assert any("secret" in f.title.lower() for f in report.findings) + + def test_stub_qa_report_shape(self) -> None: + report = stub_qa_report("m-1", ["a.py", "b.py"]) + assert report.qa_run_id == "qa-m-1-stub" + assert report.executor == "stub" + assert report.test_passed == 2 + assert report.test_failed == 0 + + +# --------------------------------------------------------------------------- +# 8. OpencodeUnavailable — exception is catchable +# --------------------------------------------------------------------------- + + +class TestOpencodeUnavailable: + def test_is_an_exception(self) -> None: + assert issubclass(OpencodeUnavailable, BaseException) + + def test_can_be_raised_and_caught(self) -> None: + with pytest.raises(OpencodeUnavailable): + raise OpencodeUnavailable("test") + + def test_message_preserved(self) -> None: + try: + raise OpencodeUnavailable("synthetic message") + except OpencodeUnavailable as e: + assert "synthetic message" in str(e) + + +# --------------------------------------------------------------------------- +# 9. Boundary guards — review_handler must NOT pull in forbidden modules +# --------------------------------------------------------------------------- + + +class TestBoundaryGuards: + def _reload_review_handler(self) -> None: + # Drop the module from sys.modules, then re-import fresh. We can't + # use importlib.reload() here because the local `review_handler` + # reference becomes stale once the module is removed from sys.modules. + # Return the set of modules that were ADDED by the re-import, so + # boundary guards can assert against the diff (other test files in + # the suite may have pre-populated sys.modules with bridge/bridge_cli). + for mod in list(sys.modules): + if mod == "executors.review_handler" or mod.startswith("executors.review_handler."): + del sys.modules[mod] + before = set(sys.modules) + importlib.import_module("executors.review_handler") + added = set(sys.modules) - before + return added + + def test_does_not_pull_kanban_feedback(self) -> None: + added = self._reload_review_handler() + kf = [m for m in added if m == "hermes_cli.kanban_feedback" or m.startswith("hermes_cli.kanban_feedback.")] + assert kf == [] + + def test_does_not_pull_executors_cli(self) -> None: + added = self._reload_review_handler() + assert "executors.cli" not in added + + def test_does_not_pull_executors_bridge(self) -> None: + added = self._reload_review_handler() + assert "executors.bridge" not in added + assert "executors.bridge_cli" not in added + + def test_no_real_opencode_invocation(self) -> None: + # Source must not call opencode directly outside the subprocess + # wrapper (which is gated by shutil.which). + src = Path(review_handler.__file__).read_text() + # The wrapper uses asyncio.create_subprocess_exec with the command + # name; that's expected. But no call should bypass shutil.which. + # We assert that the only call to opencode goes through the wrapper. + assert "shutil.which" in src # gate exists + + def test_no_db_calls(self) -> None: + # The dual-rail note mentions "task_events" descriptively in the + # docstring, so we don't assert the bare string. Instead check the + # *imports* that would pull sqlite in. + src = Path(review_handler.__file__).read_text() + assert "import sqlite3" not in src + assert "from sqlite3" not in src + + def test_does_not_write_to_kanban_sqlite(self) -> None: + # The dual-rail note explicitly forbids touching the Kanban DB. + # Verify by source inspection. + src = Path(review_handler.__file__).read_text() + # No INSERT, no _append_event, no execute() against sqlite + assert "INSERT" not in src + assert "_append_event" not in src + assert "conn.execute" not in src diff --git a/tests/executors/test_router.py b/tests/executors/test_router.py new file mode 100644 index 000000000000..4cd114e3fcdd --- /dev/null +++ b/tests/executors/test_router.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +""" +Tests for executors/router.py and related Commit A core-safe APIs. + +Scope: + - ExecutorRouter returns sensible recommendations from task goal text + - Fallback path doesn't crash on empty/garbage input + - ExecutorRegistry default manifests are loadable + - Health check does not crash when the underlying binary is missing + +Strictly no real external model calls, no worktrees, no user file writes. +""" + +from __future__ import annotations + +import asyncio +from typing import AsyncIterable + +import pytest + +from executors.health import ( + check_all_executors_health, + check_executor_health, + make_available_health, + make_unavailable_health, + make_unknown_health, +) +from executors.registry import ExecutorRegistry, _default_manifests +from executors.router import ( + ExecutorRouter, + RouteRule, + TaskCreateContext, + _FALLBACK_ORDER, + _normalize, + _score_text, + create_default_router, +) +from executors.types import ( + AdapterStartResult, + AgentExecutorAdapter, + AgentRun, + ExecutorCapabilities, + ExecutorHealthResult, + ExecutorHealthStatus, + ExecutorManifest, + RouterRecommendation, +) + + +# --------------------------------------------------------------------------- +# Stubs +# --------------------------------------------------------------------------- + +class _StubAdapter: + """Minimal AgentExecutorAdapter used only to satisfy registry.register().""" + + def __init__(self, executor_id: str = "stub") -> None: + self.executor_id = executor_id + + async def start(self, run: AgentRun, config) -> AdapterStartResult: # noqa: D401 + return AdapterStartResult(external_run_id=f"{self.executor_id}-run") + + async def stop(self, run_id: str) -> None: + return None + + async def stream_events(self, run_id: str) -> AsyncIterable: # type: ignore[override] + if False: + yield None + + async def get_status(self, run_id: str): # type: ignore[no-untyped-def] + from executors.types import RunStatus + return RunStatus.COMPLETED + + async def check_health(self) -> ExecutorHealthResult: + return make_available_health(self.executor_id, version="stub-1.0") + + +def _make_registry() -> ExecutorRegistry: + """Build an ExecutorRegistry with the default manifests + stub adapters.""" + reg = ExecutorRegistry() + for manifest in _default_manifests().values(): + reg.register(manifest, _StubAdapter(manifest.id)) + return reg + + +# --------------------------------------------------------------------------- +# Router — keyword-based recommendation +# --------------------------------------------------------------------------- + +class TestExecutorRouterKeywordMatching: + def test_routes_architecture_task_to_claude_code(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Design the new auth subsystem", + goal="Produce an ADR for the architecture", + available_executors=["claude-code", "codex-cli", "hermes-local"], + ) + rec = router.route(ctx) + assert isinstance(rec, RouterRecommendation) + assert rec.recommended_executor == "claude-code" + assert rec.source == "keyword" + assert rec.confidence > 0.0 + assert "architecture" in rec.reason.lower() or "design" in rec.reason.lower() + + def test_routes_refactor_task_to_codex_cli(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Refactor the billing module", + goal="Rewrite the API service for production", + available_executors=["claude-code", "codex-cli", "hermes-local"], + ) + rec = router.route(ctx) + assert rec.recommended_executor == "codex-cli" + + def test_routes_quick_bug_fix_to_deepseek_tui(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Fix typo in README", + goal="Quick small fix on a cosmetic issue", + available_executors=["claude-code", "codex-cli", "deepseek-tui", "hermes-local"], + ) + rec = router.route(ctx) + assert rec.recommended_executor == "deepseek-tui" + + def test_routes_hermes_internal_to_hermes_local(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Wire the gateway adapter", + goal="Update the orchestrator config for cron batch", + available_executors=["claude-code", "hermes-local"], + ) + rec = router.route(ctx) + assert rec.recommended_executor == "hermes-local" + + def test_routes_opencode_for_oss_validation(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="Validate the open-source alternative", + goal="Local offline prototype of an OSS agent", + available_executors=["claude-code", "opencode"], + ) + rec = router.route(ctx) + assert rec.recommended_executor == "opencode" + + +# --------------------------------------------------------------------------- +# Router — fallback path +# --------------------------------------------------------------------------- + +class TestExecutorRouterFallback: + def test_fallback_when_no_keyword_matches(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="do the thing", + goal="make it work", + available_executors=["codex-cli", "claude-code", "hermes-local"], + ) + rec = router.route(ctx) + assert rec.source == "health_fallback" + assert rec.recommended_executor in {"codex-cli", "claude-code", "hermes-local"} + assert 0.0 <= rec.confidence <= 1.0 + + def test_fallback_when_no_executors_available(self) -> None: + router = ExecutorRouter() + ctx = TaskCreateContext( + title="", + goal="", + available_executors=[], + ) + rec = router.route(ctx) + # Last-ditch default; should never raise. + assert isinstance(rec, RouterRecommendation) + assert rec.source == "health_fallback" + assert rec.confidence <= 0.5 + + def test_fallback_does_not_crash_on_garbage_input(self) -> None: + router = ExecutorRouter() + for raw in ["", " ", "???", "\n\n", "👻", "a" * 5000]: + ctx = TaskCreateContext( + title=raw, + goal=raw, + available_executors=["codex-cli"], + ) + rec = router.route(ctx) + assert rec.recommended_executor in {"codex-cli"} + + def test_unavailable_recommended_falls_back_to_alternative(self) -> None: + router = ExecutorRouter() + # "architecture" routes to claude-code, but it's not in the available set. + ctx = TaskCreateContext( + title="Architecture review", + goal="ADR for the new module", + available_executors=["codex-cli", "hermes-local"], + ) + rec = router.route(ctx) + # Must not raise; must return a recommendation within the available set. + assert rec.recommended_executor in {"codex-cli", "hermes-local"} + + def test_fallback_order_includes_known_executors(self) -> None: + # Sanity check on the default fallback chain — guards against typos. + for eid in _FALLBACK_ORDER: + assert isinstance(eid, str) + assert eid + + +# --------------------------------------------------------------------------- +# Router — text helpers +# --------------------------------------------------------------------------- + +class TestRouterHelpers: + def test_normalize_lowercases_and_collapses_whitespace(self) -> None: + assert _normalize(" Hello WORLD ") == "hello world" + + def test_score_text_zero_for_no_match(self) -> None: + assert _score_text("nothing relevant", ["foo", "bar"]) == 0.0 + + def test_score_text_positive_for_match(self) -> None: + score = _score_text("please refactor this", ["refactor"]) + assert score > 0.0 + assert score <= 1.0 + + def test_custom_rules_override_defaults(self) -> None: + custom_rule = RouteRule( + executor="opencode", + keywords=["unicorn"], + reason_template="matched unicorn", + priority=100, + confidence=0.99, + ) + router = ExecutorRouter(rules=[custom_rule]) + ctx = TaskCreateContext( + title="Unicorn migration", + goal="deploy the unicorn", + available_executors=["claude-code", "opencode"], + ) + rec = router.route(ctx) + assert rec.recommended_executor == "opencode" + + def test_create_default_router_factory_works(self) -> None: + router = create_default_router() + assert isinstance(router, ExecutorRouter) + rec = router.route( + TaskCreateContext(title="x", goal="y", available_executors=["codex-cli"]) + ) + assert isinstance(rec, RouterRecommendation) + + +# --------------------------------------------------------------------------- +# Registry — default manifests loadable +# --------------------------------------------------------------------------- + +class TestRegistryDefaultManifests: + def test_default_manifests_contains_known_executors(self) -> None: + manifests = _default_manifests() + for required in ["hermes-local", "claude-code", "codex-cli", "deepseek-tui", "opencode"]: + assert required in manifests, f"missing default manifest: {required}" + assert isinstance(manifests[required], ExecutorManifest) + + def test_registry_round_trip(self) -> None: + reg = _make_registry() + assert "claude-code" in {m.id for m in reg.list_executors()} + adapter = reg.get("claude-code") + assert isinstance(adapter, AgentExecutorAdapter) + manifest = reg.get_manifest("claude-code") + assert manifest.id == "claude-code" + assert isinstance(manifest.capabilities, ExecutorCapabilities) + + def test_registry_unknown_id_raises(self) -> None: + reg = _make_registry() + with pytest.raises(KeyError): + reg.get("does-not-exist") + with pytest.raises(KeyError): + reg.get_manifest("does-not-exist") + + def test_list_available_filters_by_health(self) -> None: + reg = _make_registry() + # Default health is UNKNOWN for all → nothing listed as available. + assert reg.list_available() == [] + # Promote one to AVAILABLE and re-check. + reg.set_health(make_available_health("codex-cli")) + available_ids = {m.id for m in reg.list_available()} + assert "codex-cli" in available_ids + assert "claude-code" not in available_ids + + +# --------------------------------------------------------------------------- +# Health — does not crash on missing binaries +# --------------------------------------------------------------------------- + +class TestHealthMissingBinary: + def test_check_executor_health_returns_unavailable_for_missing_command(self) -> None: + # 'definitely-not-a-real-binary-xyz123' cannot exist on PATH. + result = asyncio.run( + check_executor_health("phantom-exec", "definitely-not-a-real-binary-xyz123") + ) + assert isinstance(result, ExecutorHealthResult) + assert result.executor_id == "phantom-exec" + assert result.status == ExecutorHealthStatus.UNAVAILABLE + assert result.error is not None + assert "not found" in result.error.lower() or "PATH" in result.error + + def test_check_all_executors_health_handles_empty_registry(self) -> None: + reg = ExecutorRegistry() + results = asyncio.run(check_all_executors_health(reg)) + assert results == {} + + def test_check_all_executors_health_does_not_raise_on_unknown_adapter(self) -> None: + # Build a registry with a manifest whose adapter has no check_health(). + reg = ExecutorRegistry() + + class _NoHealthAdapter: + pass + + reg.register( + ExecutorManifest( + id="no-health", + label="No Health", + description="adapter without check_health", + ), + _NoHealthAdapter(), + ) + results = asyncio.run(check_all_executors_health(reg)) + assert "no-health" in results + # The aggregator should fall back to UNKNOWN rather than crashing. + assert results["no-health"].status == ExecutorHealthStatus.UNKNOWN + + def test_health_factory_helpers(self) -> None: + u = make_unknown_health("x") + assert u.status == ExecutorHealthStatus.UNKNOWN + a = make_available_health("x", version="1.0") + assert a.status == ExecutorHealthStatus.AVAILABLE + assert a.version == "1.0" + n = make_unavailable_health("x", error="boom") + assert n.status == ExecutorHealthStatus.UNAVAILABLE + assert n.error == "boom" + + +# --------------------------------------------------------------------------- +# Public API smoke +# --------------------------------------------------------------------------- + +class TestPublicAPISmoke: + def test_importing_executors_package_does_not_pull_adapters(self) -> None: + import executors as pkg + for name in [ + "ExecutorId", + "ExecutorRegistry", + "ExecutorRouter", + "RouterRecommendation", + "TaskCreateContext", + "check_executor_health", + "create_default_router", + ]: + assert name in pkg.__all__, f"missing public export: {name}" + # Adapters must NOT be re-exported by the core-safe API. + for adapter_name in [ + "HermesLocalAdapter", + "ClaudeCodeAdapter", + "CodexAdapter", + "OpenCodeAdapter", + "DeepSeekTuiAdapter", + ]: + assert adapter_name not in pkg.__all__, ( + f"adapter leaked into core-safe API: {adapter_name}" + ) + assert not hasattr(pkg, adapter_name), ( + f"adapter leaked into executors package namespace: {adapter_name}" + ) diff --git a/tests/executors/test_worktree.py b/tests/executors/test_worktree.py new file mode 100644 index 000000000000..9ac5b0ee8168 --- /dev/null +++ b/tests/executors/test_worktree.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +""" +Tests for executors/worktree.py and executors/worktree_cli.py. + +These tests use REAL git operations against repos created in pytest's +tmp_path. They never touch /Users/gu/.hermes/hermes-agent. + +Coverage: + 1. _check_clean_working_tree bug fix (the previous version was + dead-code due to a mis-indented function body — dirty main repos + were silently accepted). + 2. .hermes/ infrastructure files are filtered out of the dirty check. + 3. worktree create / status / list basic paths. + 4. discard confirmation gate (already existed — tests cover it). + 5. merge confirmation gate (NEW safety fix — destructive operations + now require explicit confirmation or --force). + 6. Safety: nothing in the worktree subsystem ever reaches the real + /Users/gu/.hermes/hermes-agent repo. +""" + +from __future__ import annotations + +import asyncio +import subprocess +from pathlib import Path + +import pytest + +from executors.worktree import WorktreeManager +from executors.worktree_cli import cmd_discard, cmd_merge +from executors.types import WorktreeStatus + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _git(*args: str, cwd: Path, check: bool = True) -> subprocess.CompletedProcess: + """Sync git helper for fixture / test setup.""" + result = subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + raise RuntimeError( + f"git {' '.join(args)} failed (rc={result.returncode}): {result.stderr}" + ) + return result + + +def _run(coro): + """Drive an async coroutine on a fresh event loop.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def tmp_git_repo(tmp_path: Path) -> Path: + """A clean git repo in tmp_path with one initial commit on `main`.""" + repo = tmp_path / "repo" + repo.mkdir() + _git("init", "--initial-branch=main", cwd=repo) + _git("config", "user.email", "test@example.com", cwd=repo) + _git("config", "user.name", "Test User", cwd=repo) + (repo / "README.md").write_text("hello\n") + _git("add", "README.md", cwd=repo) + _git("commit", "-m", "initial commit", cwd=repo) + return repo + + +@pytest.fixture +def manager(tmp_git_repo: Path) -> WorktreeManager: + return WorktreeManager(tmp_git_repo) + + +# --------------------------------------------------------------------------- +# 1. _check_clean_working_tree — bug fix +# --------------------------------------------------------------------------- + +class TestDirtyCheckBugFix: + """The previous implementation had the dirty-check body indented as + if it belonged to the `if not r.ok:` block. The result: the entire + dirty-check was dead code; a dirty main repo would silently pass. + These tests would FAIL against the buggy code (the alloc would be + READY when it should be FAILED) and PASS against the fix. + """ + + def test_untracked_file_rejects_create( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + (tmp_git_repo / "untracked.txt").write_text("dirty\n") + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + assert "uncommitted" in alloc.error.lower() + # No worktree was actually created. + assert not (tmp_git_repo / ".hermes" / "worktrees" / "bc12345").exists() + + def test_modified_tracked_file_rejects_create( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + (tmp_git_repo / "README.md").write_text("modified content\n") + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + assert "uncommitted" in alloc.error.lower() + + def test_staged_file_rejects_create( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + (tmp_git_repo / "staged.txt").write_text("staged\n") + _git("add", "staged.txt", cwd=tmp_git_repo) + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + assert "uncommitted" in alloc.error.lower() + + def test_hermes_infra_untracked_is_ignored( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + """Files inside .hermes/ (context.json, inbox.json, etc.) are + managed by the agent itself, so a dirty .hermes/ must NOT + block worktree creation. + """ + hermes = tmp_git_repo / ".hermes" + hermes.mkdir() + (hermes / "context.json").write_text("{}") + (hermes / "inbox.json").write_text("[]") + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.READY + + def test_mixed_hermes_and_user_dirty_is_rejected( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + """If BOTH .hermes/ infra files AND user files are dirty, only + the user files should be reported — and create must still fail. + """ + hermes = tmp_git_repo / ".hermes" + hermes.mkdir() + (hermes / "context.json").write_text("{}") + (tmp_git_repo / "user_change.txt").write_text("user\n") + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + assert "uncommitted" in alloc.error.lower() + # The user file is in the error; the .hermes/ file is filtered out. + assert "user_change.txt" in alloc.error + assert "context.json" not in alloc.error + + def test_clean_repo_passes_through(self, manager: WorktreeManager) -> None: + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.READY + + def test_dirty_count_in_error_uses_filtered_count( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + """The error message should report the filtered count (not + the truncated-to-5 count and not the unfiltered total). + """ + # 7 dirty user files, 1 .hermes/ file (filtered out). + hermes = tmp_git_repo / ".hermes" + hermes.mkdir() + (hermes / "context.json").write_text("{}") + for i in range(7): + (tmp_git_repo / f"file_{i}.txt").write_text(f"x{i}\n") + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + # Should mention "7 files" (filtered count), not "5 files" (truncated). + assert "7 files" in alloc.error + + +# --------------------------------------------------------------------------- +# 2. Create worktree (clean path) +# --------------------------------------------------------------------------- + +class TestCreateWorktree: + def test_create_on_clean_repo_succeeds( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + alloc = _run(manager.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.READY + assert alloc.branch_name == "hermes/bc12345/1" + assert alloc.worktree_path.endswith("/.hermes/worktrees/bc12345") + assert alloc.base_commit is not None + # Worktree directory exists on disk. + assert (tmp_git_repo / ".hermes" / "worktrees" / "bc12345").is_dir() + # Branch exists in git. + result = _git("branch", "--list", "hermes/bc12345/1", cwd=tmp_git_repo) + assert "hermes/bc12345/1" in result.stdout + + def test_create_in_non_git_dir_fails(self, tmp_path: Path) -> None: + not_a_repo = tmp_path / "not_a_repo" + not_a_repo.mkdir() + mgr = WorktreeManager(not_a_repo) + alloc = _run(mgr.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + assert "git" in (alloc.error or "").lower() + + def test_create_is_idempotent_when_already_ready( + self, manager: WorktreeManager + ) -> None: + a1 = _run(manager.create("bc12345", run_seq=1)) + assert a1.status == WorktreeStatus.READY + a2 = _run(manager.create("bc12345", run_seq=1)) + # Same allocation returned, no second worktree. + assert a2.status == WorktreeStatus.READY + assert a2.thread_id == a1.thread_id + assert a2.branch_name == a1.branch_name + + def test_gitignore_is_extended_with_worktrees_dir( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + gitignore_text = (tmp_git_repo / ".gitignore").read_text() + assert ".hermes/worktrees/" in gitignore_text + + +# --------------------------------------------------------------------------- +# 3. Status / list +# --------------------------------------------------------------------------- + +class TestStatusAndList: + def test_get_status_returns_allocation(self, manager: WorktreeManager) -> None: + _run(manager.create("bc12345", run_seq=1)) + status = _run(manager.get_status("bc12345")) + assert status is not None + assert status.thread_id == "bc12345" + assert status.status in (WorktreeStatus.READY, WorktreeStatus.DIRTY) + + def test_get_status_for_unknown_thread_returns_none( + self, manager: WorktreeManager + ) -> None: + assert _run(manager.get_status("nonexistent")) is None + + def test_get_status_marks_dirty_after_modification( + self, manager: WorktreeManager, tmp_git_repo: Path + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("new content\n") + status = _run(manager.get_status("bc12345")) + assert status.status == WorktreeStatus.DIRTY + assert status.changed_files_count >= 1 + + def test_list_active_excludes_discarded( + self, manager: WorktreeManager + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + _run(manager.create("task-12345678", run_seq=1)) + assert len(manager.list_active()) == 2 + _run(manager.discard("task-12345678")) + active = manager.list_active() + assert len(active) == 1 + assert active[0].thread_id == "bc12345" + + def test_list_all_includes_discarded( + self, manager: WorktreeManager + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + _run(manager.discard("bc12345")) + all_allocs = manager.list_all() + assert len(all_allocs) == 1 + assert all_allocs[0].status == WorktreeStatus.DISCARDED + + +# --------------------------------------------------------------------------- +# 4. Discard — confirmation gate (existing) +# --------------------------------------------------------------------------- + +class TestDiscardConfirmationGate: + def test_force_skips_confirmation( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("x\n") + # If input() were called the test would explode. + def fail_input(*args, **kwargs): + raise AssertionError("input() was called despite force=True") + monkeypatch.setattr("builtins.input", fail_input) + _run(cmd_discard(manager, "bc12345", force=True)) + status = _run(manager.get_status("bc12345")) + assert status.status == WorktreeStatus.DISCARDED + + def test_no_response_cancels_discard( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch, capsys + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("x\n") + monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") + _run(cmd_discard(manager, "bc12345", force=False)) + capsys.readouterr() # consume + status = _run(manager.get_status("bc12345")) + # Discard was cancelled — worktree still active. + assert status.status != WorktreeStatus.DISCARDED + + def test_yes_response_proceeds_with_discard( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("x\n") + monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") + _run(cmd_discard(manager, "bc12345", force=False)) + status = _run(manager.get_status("bc12345")) + assert status.status == WorktreeStatus.DISCARDED + + +# --------------------------------------------------------------------------- +# 5. Merge — confirmation gate (NEW safety fix) +# --------------------------------------------------------------------------- + +class TestMergeConfirmationGate: + """Previously, ``cmd_merge`` had no confirmation gate. It would + directly call ``mgr.merge()`` which runs ``git add -A``, + ``git commit``, and ``git merge --no-ff`` on the main repo. This is + a destructive operation that must require explicit user consent. + """ + + def test_force_skips_confirmation( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("merged content\n") + # If input() were called the test would explode. + def fail_input(*args, **kwargs): + raise AssertionError("input() was called despite force=True") + monkeypatch.setattr("builtins.input", fail_input) + _run(cmd_merge(manager, "bc12345", force=True)) + # The merge commit exists in main's history. + log = _git("log", "--oneline", "--all", cwd=tmp_git_repo) + assert "hermes: merge" in log.stdout.lower() + + def test_no_response_cancels_merge( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch, capsys + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("unmerged content\n") + monkeypatch.setattr("builtins.input", lambda *a, **kw: "n") + _run(cmd_merge(manager, "bc12345", force=False)) + captured = capsys.readouterr() + # The branch is still alive — no merge happened. + result = _git("branch", "--list", "hermes/bc12345/1", cwd=tmp_git_repo) + assert "hermes/bc12345/1" in result.stdout + # The main repo has no merge commit. + log = _git("log", "--oneline", cwd=tmp_git_repo) + assert "hermes: merge" not in log.stdout.lower() + # The gate was actually triggered (the "This will commit..." + # preamble is printed before input() is called). + assert "this will commit" in captured.out.lower() + + def test_yes_response_proceeds_with_merge( + self, manager: WorktreeManager, tmp_git_repo: Path, monkeypatch + ) -> None: + _run(manager.create("bc12345", run_seq=1)) + wt = tmp_git_repo / ".hermes" / "worktrees" / "bc12345" + (wt / "new.txt").write_text("merged content\n") + monkeypatch.setattr("builtins.input", lambda *a, **kw: "y") + _run(cmd_merge(manager, "bc12345", force=False)) + log = _git("log", "--oneline", "--all", cwd=tmp_git_repo) + assert "hermes: merge" in log.stdout.lower() + + +# --------------------------------------------------------------------------- +# 6. Safety: never touches the real repo +# --------------------------------------------------------------------------- + +class TestNoRealRepoSideEffects: + """The worktree subsystem must NEVER point at + /Users/gu/.hermes/hermes-agent unless explicitly told to. These + tests are a safety net — if a refactor ever starts defaulting to a + hard-coded path, these fail. + """ + + REAL_REPO = Path("/Users/gu/.hermes/hermes-agent") + + def test_manager_pointed_at_tmp_repo_does_not_touch_real_repo( + self, tmp_path: Path + ) -> None: + repo = tmp_path / "isolated_repo" + repo.mkdir() + _git("init", "--initial-branch=main", cwd=repo) + _git("config", "user.email", "t@t.com", cwd=repo) + _git("config", "user.name", "T", cwd=repo) + (repo / "f.txt").write_text("x") + _git("add", "f.txt", cwd=repo) + _git("commit", "-m", "init", cwd=repo) + mgr = WorktreeManager(repo) + alloc = _run(mgr.create("task-zzz99999", run_seq=1)) + assert alloc.status == WorktreeStatus.READY + # The real repo's worktrees dir is untouched. + real_wt = self.REAL_REPO / ".hermes" / "worktrees" / "zzz99999" + assert not real_wt.exists(), ( + f"Unexpected write to {real_wt} — WorktreeManager reached the real repo" + ) + + def test_manager_rejects_path_outside_git_repo( + self, tmp_path: Path + ) -> None: + not_a_repo = tmp_path / "not_a_repo" + not_a_repo.mkdir() + mgr = WorktreeManager(not_a_repo) + alloc = _run(mgr.create("bc12345", run_seq=1)) + assert alloc.status == WorktreeStatus.FAILED + # No real-repo side effects. + real_wt = self.REAL_REPO / ".hermes" / "worktrees" / "bc12345" + assert not real_wt.exists() diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index c59578c4b6ae..44ecaf313a4d 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -355,6 +355,41 @@ def test_kanban_autocomplete_includes_live_subcommands(): assert "reassign" in texts +def test_dispatch_records_diff_event_for_spawned_task(kanban_home, monkeypatch): + from hermes_cli import profiles + + monkeypatch.setattr(profiles, "profile_exists", lambda _profile: True) + monkeypatch.setattr(kb, "_default_spawn", lambda _task, _workspace, **_kw: 12345) + + with kb.connect() as conn: + tid = kb.create_task(conn, title="diff me", assignee="worker") + + out = kc.run_slash("dispatch") + assert "Spawned" in out + + with kb.connect() as conn: + events = kb.list_events(conn, tid) + diff_events = [e for e in events if e.kind == "diff"] + assert diff_events + payload = diff_events[-1].payload or {} + assert "unified_diff" in payload + assert isinstance(payload.get("files"), list) + + +def test_review_and_qa_commands_record_result_events(kanban_home, monkeypatch): + monkeypatch.setenv("PATH", "") + with kb.connect() as conn: + tid = kb.create_task(conn, title="review me") + + assert "Review complete" in kc.run_slash(f"review {tid}") + assert "QA complete" in kc.run_slash(f"qa {tid}") + + with kb.connect() as conn: + kinds = [e.kind for e in kb.list_events(conn, tid)] + assert "review_result" in kinds + assert "qa_result" in kinds + + def test_kanban_not_gateway_only(): # kanban is available in BOTH CLI and gateway surfaces. from hermes_cli.commands import COMMAND_REGISTRY diff --git a/web/src/App.tsx b/web/src/App.tsx index aef3148b747c..b3395610bc31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -94,7 +94,12 @@ import { api } from "@/lib/api"; import type { StatusResponse } from "@/lib/api"; function RootRedirect() { - return <Navigate to="/sessions" replace />; + return ( + <Navigate + to={isDashboardEmbeddedChatEnabled() ? "/chat" : "/operations"} + replace + /> + ); } function UnknownRouteFallback({ pluginsLoading }: { pluginsLoading: boolean }) { @@ -102,7 +107,12 @@ function UnknownRouteFallback({ pluginsLoading }: { pluginsLoading: boolean }) { // Render nothing during the plugin-load window — a spinner here would just flash. return null; } - return <Navigate to="/sessions" replace />; + return ( + <Navigate + to={isDashboardEmbeddedChatEnabled() ? "/chat" : "/sessions"} + replace + /> + ); } const CHAT_NAV_ITEM: NavItem = { @@ -112,6 +122,19 @@ const CHAT_NAV_ITEM: NavItem = { icon: Terminal, }; +/** Backend-dashboard entries hidden from sidebar when embedded chat is on. + * Routes remain reachable by URL — only the nav links are removed so the + * sidebar reads like a Codex.app chat shell. */ +const HIDDEN_IN_CHAT_MODE_PATHS: ReadonlySet<string> = new Set([ + "/operations", + "/sessions", + "/analytics", + "/models", + "/delegations", + "/runs", + "/logs", +]); + /** * Built-in routes except /chat. Chat is rendered persistently (outside * <Routes>) when embedded — see the persistent chat host block rendered @@ -414,12 +437,17 @@ export default function App() { ); const builtinNav = useMemo(() => { + // When embedded chat is on, filter out backend-dashboard entries + // so the sidebar nav reads like a Codex.app chat shell: + // Chat · Skills · Agents · Cron · Config · Keys · Docs. const base = embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST; - return showTokenAnalytics - ? base - : base.filter((n) => n.path !== "/analytics"); + const nav = showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics"); + if (embeddedChat) { + return nav.filter((n) => !HIDDEN_IN_CHAT_MODE_PATHS.has(n.path)); + } + return nav; }, [embeddedChat, showTokenAnalytics]); const sidebarNav = useMemo( @@ -882,7 +910,7 @@ function SidebarSystemActions({ const handleClick = (action: SystemAction) => { if (isBusy) return; void runAction(action); - navigate("/sessions"); + navigate(isDashboardEmbeddedChatEnabled() ? "/chat" : "/sessions"); onNavigate(); }; diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index ec8ffa442e85..79ca8462a592 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -25,15 +25,14 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; -import { Card } from "@nous-research/ui/ui/components/card"; +import { Card } from "@/components/ui/card"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; -import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; -import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; +import { AlertCircle, Check, ChevronDown, ChevronLeft, FileText, RefreshCw, Zap } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; interface SessionInfo { @@ -69,12 +68,188 @@ const STATE_TONE: Record< error: "destructive", }; +/* ── Structured preview parser ──────────────────────────────────── */ + +interface StructuredPreview { + unified_diff?: string; + files?: string[]; + review_findings?: { file?: string; line?: number; severity?: string; message: string }[]; + review_summary?: string; + qa_passed?: number; + qa_failed?: number; + qa_checks?: { name: string; passed: boolean; detail?: string }[]; + qa_summary?: string; + artifact_title?: string; + artifact_path?: string; + artifact_url?: string; + artifact_content?: string; + artifact_preview?: string; + raw?: unknown; +} + +/** Best-effort: resolve an artifact sub-object from one of several common + * nesting patterns in JSON tool summaries. */ +function resolveArtifactObject( + obj: Record<string, unknown>, +): Record<string, unknown> | null { + // Direct flat shape: { title, path, url, content, preview } + if ( + typeof obj.title === "string" || + typeof obj.path === "string" || + typeof obj.url === "string" || + typeof obj.content === "string" + ) { + return obj; + } + // { artifact: { title, ... } } + if (obj.artifact && typeof obj.artifact === "object") { + return obj.artifact as Record<string, unknown>; + } + // { result: { artifact: { title, ... } } } + if (obj.result && typeof obj.result === "object") { + const r = obj.result as Record<string, unknown>; + if (r.artifact && typeof r.artifact === "object") { + return r.artifact as Record<string, unknown>; + } + } + // { artifacts: [{ title, ... }] } + if (Array.isArray(obj.artifacts) && obj.artifacts.length > 0) { + const first = obj.artifacts[0]; + if (first && typeof first === "object") return first as Record<string, unknown>; + } + // { result: { artifacts: [{ title, ... }] } } + if (obj.result && typeof obj.result === "object") { + const r = obj.result as Record<string, unknown>; + if (Array.isArray(r.artifacts) && r.artifacts.length > 0) { + const first = r.artifacts[0]; + if (first && typeof first === "object") return first as Record<string, unknown>; + } + } + return null; +} + +/** Best-effort parse of a tool's summary/fields into structured sections. + * Returns null when nothing structured was recognized — the caller falls + * back to the existing generic render. */ +function parseStructuredPreview(tool: ToolEntry): StructuredPreview | null { + if (tool.error) return null; // errors take priority, skip structured parse + + const parsed: StructuredPreview = {}; + + // 1. inline_diff → unified_diff + files + if (tool.inline_diff) { + parsed.unified_diff = tool.inline_diff; + // Extract file names from diff headers (--- a/... / +++ b/...) + const fileSet = new Set<string>(); + for (const m of tool.inline_diff.matchAll(/^[-+]{3} [ab]\/(.+)$/gm)) { + fileSet.add(m[1]); + } + const files = [...fileSet]; + if (files.length > 0) parsed.files = files; + } + + // 2. Try JSON parse on summary + if (tool.summary) { + try { + const obj = JSON.parse(tool.summary); + if (obj && typeof obj === "object") { + parsed.raw = obj; + + // Review result shape: { findings, summary, ... } + if (Array.isArray(obj.findings)) { + parsed.review_findings = obj.findings.map( + (f: Record<string, unknown>) => ({ + file: typeof f.file === "string" ? f.file : undefined, + line: typeof f.line === "number" ? f.line : undefined, + severity: typeof f.severity === "string" ? f.severity : undefined, + message: + typeof f.message === "string" + ? f.message + : JSON.stringify(f), + }), + ); + } + if (typeof obj.summary === "string") parsed.review_summary = obj.summary; + if (typeof obj.note === "string" && !parsed.review_summary) + parsed.review_summary = obj.note; + + // QA result shape: { test_passed, test_failed, checks, ... } + if (typeof obj.test_passed === "number") + parsed.qa_passed = obj.test_passed; + if (typeof obj.test_failed === "number") + parsed.qa_failed = obj.test_failed; + if (typeof obj.test_skipped === "number") + parsed.qa_passed = (parsed.qa_passed ?? 0) + obj.test_skipped; + if (Array.isArray(obj.checks)) { + parsed.qa_checks = obj.checks.map( + (c: Record<string, unknown>) => ({ + name: typeof c.name === "string" ? c.name : String(c.name ?? "?"), + passed: c.passed !== false, + detail: typeof c.detail === "string" ? c.detail : undefined, + }), + ); + } + if (typeof obj.note === "string" && !parsed.qa_summary) + parsed.qa_summary = obj.note; + + // Artifact shape — try multiple common nesting patterns: + // { title, path, url, content, preview } + // { artifact: { title, path, ... } } + // { artifacts: [{ title, path, ... }] } + const artifactObj = resolveArtifactObject(obj); + if (artifactObj) { + if (typeof artifactObj.title === "string") + parsed.artifact_title = artifactObj.title; + if (typeof artifactObj.path === "string") + parsed.artifact_path = artifactObj.path; + if (typeof artifactObj.url === "string") + parsed.artifact_url = artifactObj.url; + if (typeof artifactObj.content === "string") + parsed.artifact_content = artifactObj.content; + if (typeof artifactObj.preview === "string") + parsed.artifact_preview = artifactObj.preview; + } + + // Nested diff fields in JSON + if (!parsed.unified_diff && typeof obj.unified_diff === "string") { + parsed.unified_diff = obj.unified_diff; + } + if (!parsed.files && Array.isArray(obj.files)) { + parsed.files = obj.files.map((f: unknown) => String(f)); + } + } + } catch { + // Not JSON — summary is plain text, no structured parse + } + } + + // Determine if we found anything structured + const hasStructured = + parsed.unified_diff || + parsed.files || + parsed.review_findings || + parsed.review_summary || + parsed.qa_passed !== undefined || + parsed.qa_checks || + parsed.qa_summary || + parsed.artifact_title || + parsed.artifact_path || + parsed.artifact_url || + parsed.artifact_content || + parsed.artifact_preview; + + return hasStructured ? parsed : null; +} + +/* ── Component ───────────────────────────────────────────────────── */ + interface ChatSidebarProps { channel: string; + taskId?: string; className?: string; } -export function ChatSidebar({ channel, className }: ChatSidebarProps) { +export function ChatSidebar({ channel, taskId, className }: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState // for it inside an effect (React 19's set-state-in-effect rule). The // counter is the dependency on purpose — it's not read in the memo body, @@ -89,6 +264,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { const [tools, setTools] = useState<ToolEntry[]>([]); const [modelOpen, setModelOpen] = useState(false); const [error, setError] = useState<string | null>(null); + const [selectedToolId, setSelectedToolId] = useState<string | null>(null); useEffect(() => { let cancelled = false; @@ -152,44 +328,37 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { // JSON-RPC sidecar so the sidebar matches its documented best-effort // UX and the user always has a reconnect affordance. useEffect(() => { - if (!channel) { + const token = window.__HERMES_SESSION_TOKEN__; + + if (!token || !channel) { return; } - // In loopback mode the legacy ?token=<session> path is fine; in gated - // mode we have to mint a single-use ticket from the cookie. The IIFE - // keeps the outer effect synchronous so its ``return cleanup`` stays - // at the top level; the local ``ws`` is hoisted to a closed-over - // binding the cleanup reads via ``wsRef``. + + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams({ token, channel }); + if (taskId) qs.set("task_id", taskId); + const ws = new WebSocket( + `${proto}//${window.location.host}/api/events?${qs.toString()}`, + ); + + // `unmounting` suppresses the banner during cleanup — `ws.close()` + // from the effect's return fires a close event with code 1005 that + // would otherwise look like an unexpected drop. + const DISCONNECTED = "events feed disconnected — tool calls may not appear"; let unmounting = false; - let ws: WebSocket | null = null; - void (async () => { - const [authName, authValue] = await buildWsAuthParam(); - if (!authValue || unmounting) { - return; + const surface = (msg: string) => !unmounting && setError(msg); + + ws.addEventListener("error", () => surface(DISCONNECTED)); + + ws.addEventListener("close", (ev) => { + if (ev.code === 4401 || ev.code === 4403) { + surface(`events feed rejected (${ev.code}) — reload the page`); + } else if (ev.code !== 1000) { + surface(DISCONNECTED); } - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams({ [authName]: authValue, channel }); - ws = new WebSocket( - `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`, - ); - - // `unmounting` suppresses the banner during cleanup — `ws.close()` - // from the effect's return fires a close event with code 1005 that - // would otherwise look like an unexpected drop. - const DISCONNECTED = "events feed disconnected — tool calls may not appear"; - const surface = (msg: string) => !unmounting && setError(msg); - - ws.addEventListener("error", () => surface(DISCONNECTED)); - - ws.addEventListener("close", (ev) => { - if (ev.code === 4401 || ev.code === 4403) { - surface(`events feed rejected (${ev.code}) — reload the page`); - } else if (ev.code !== 1000) { - surface(DISCONNECTED); - } - }); + }); - ws.addEventListener("message", (ev) => { + ws.addEventListener("message", (ev) => { let frame: RpcEnvelope; try { @@ -272,15 +441,24 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { : t, ), ); + + if (p.error) { + queueMicrotask(() => { + setTools((prev) => { + const entry = prev.find((t) => t.tool_id === p.tool_id); + if (entry) setSelectedToolId(entry.id); + return prev; + }); + }); + } } - }); - })(); + }); return () => { unmounting = true; - ws?.close(); + ws.close(); }; - }, [channel, version]); + }, [channel, taskId, version]); const reconnect = useCallback(() => { setError(null); @@ -309,17 +487,31 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { const canPickModel = state === "open" && !!sessionId; const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—"; const banner = error ?? info.credential_warning ?? null; + const runningCount = tools.filter((t) => t.status === "running").length; + const selectedTool = tools.find((t) => t.id === selectedToolId) ?? null; + const structuredPreview = useMemo( + () => (selectedTool ? parseStructuredPreview(selectedTool) : null), + [selectedTool], + ); + + const handleToolClick = useCallback((tool: ToolEntry) => { + setSelectedToolId(tool.id); + }, []); + + const handleDeselect = useCallback(() => { + setSelectedToolId(null); + }, []); return ( <aside className={cn( - "flex h-full w-full min-w-0 shrink-0 flex-col gap-3 overflow-y-auto overflow-x-hidden pr-1 lg:w-80", + "flex h-full w-full min-w-0 shrink-0 flex-col gap-2.5 overflow-y-auto overflow-x-hidden pr-1 normal-case", className, )} > - <Card className="flex items-center justify-between gap-2 px-3 py-2"> + <div className="flex items-center justify-between gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"> <div className="min-w-0"> - <div className="text-display text-xs tracking-wider text-text-tertiary"> + <div className="text-[0.6rem] uppercase tracking-[0.1em] text-muted-foreground/70"> model </div> @@ -330,18 +522,20 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { onClick={() => setModelOpen(true)} suffix={ canPickModel ? ( - <ChevronDown className="text-text-secondary" /> + <ChevronDown className="opacity-60 h-3 w-3" /> ) : undefined } - className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline" + className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-[0.72rem] font-medium hover:underline disabled:no-underline" title={info.model ?? "switch model"} > <span className="truncate">{modelLabel}</span> </Button> </div> - <Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge> - </Card> + <Badge tone={STATE_TONE[state]} className="text-[0.58rem] px-1.5 py-0"> + {STATE_LABEL[state]} + </Badge> + </div> {banner && ( <Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs"> @@ -365,22 +559,346 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { </Card> )} - <Card className="flex min-h-0 flex-none flex-col px-2 py-2"> - <div className="text-display px-1 pb-2 text-xs tracking-wider text-text-tertiary"> - tools + <Card className="flex min-h-0 flex-1 flex-col overflow-hidden px-2 py-2"> + <div className="flex items-center justify-between px-1 pb-2"> + <span className="text-xs uppercase tracking-wider text-muted-foreground"> + Activity + </span> + {runningCount > 0 && ( + <span className="inline-flex items-center gap-1 text-[0.6rem] tabular-nums text-primary/80"> + <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse" /> + {runningCount} running + </span> + )} </div> - <div className="flex min-h-0 flex-col gap-1.5"> + <div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto"> {tools.length === 0 ? ( - <div className="px-2 py-4 text-center text-xs text-text-secondary"> - no tool calls yet + <div className="px-2 py-6 text-center text-[0.65rem] text-muted-foreground/60"> + No activity yet </div> ) : ( - tools.map((t) => <ToolCall key={t.id} tool={t} />) + tools.map((t) => ( + <ToolCall + key={t.id} + tool={t} + onClick={() => handleToolClick(t)} + selected={t.id === selectedToolId} + /> + )) )} </div> </Card> + {/* Detail preview panel — shown when a tool is selected */} + {selectedTool && ( + <Card className="flex shrink-0 flex-col overflow-hidden border-primary/30 max-h-[55%]"> + <div className="flex items-center gap-1.5 border-b border-border/50 px-3 py-1.5"> + <Button + ghost + size="icon" + onClick={handleDeselect} + className="h-5 w-5 shrink-0 text-muted-foreground hover:text-foreground" + aria-label="Back to activity list" + > + <ChevronLeft className="h-3.5 w-3.5" /> + </Button> + <span className="text-[0.58rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + Detail + </span> + <span className="flex-1 truncate text-[0.7rem] font-mono font-medium"> + {selectedTool.name} + </span> + {selectedTool.status === "error" && ( + <Badge tone="destructive" className="text-[0.58rem] px-1.5 py-0"> + error + </Badge> + )} + {selectedTool.status === "running" && ( + <Badge tone="warning" className="text-[0.58rem] px-1.5 py-0"> + running + </Badge> + )} + </div> + + <div className="flex-1 overflow-y-auto px-3 py-2 space-y-2 text-xs font-mono"> + {/* Error first — most important */} + {selectedTool.error && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-destructive/80"> + error + </div> + <pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-destructive/[0.06] px-2 py-1.5 text-[0.7rem] leading-relaxed text-destructive"> + {selectedTool.error} + </pre> + </div> + )} + + {/* ── Structured preview (when available) ── */} + + {/* Diff — with file list header */} + {structuredPreview?.unified_diff && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + diff + </div> + {structuredPreview.files && structuredPreview.files.length > 0 && ( + <div className="flex flex-wrap gap-1 mb-1.5"> + {structuredPreview.files.map((f) => ( + <span + key={f} + className="inline-flex items-center rounded border border-border/60 px-1.5 py-0.5 text-[0.58rem] text-muted-foreground/80" + > + <FileText className="mr-1 h-2.5 w-2.5 shrink-0 opacity-60" /> + {f} + </span> + ))} + </div> + )} + <pre className="max-h-48 overflow-auto whitespace-pre rounded bg-muted/30 px-2 py-1.5 text-[0.7rem] leading-snug"> + {structuredPreview.unified_diff.split("\n").map((line, i) => ( + <div + key={i} + className={ + line.startsWith("+") && !line.startsWith("+++") + ? "text-emerald-500" + : line.startsWith("-") && !line.startsWith("---") + ? "text-destructive" + : line.startsWith("@@") + ? "text-primary" + : "text-muted-foreground/80" + } + > + {line || "\u00A0"} + </div> + ))} + </pre> + </div> + )} + + {/* Review — findings list + summary */} + {(structuredPreview?.review_findings || structuredPreview?.review_summary) && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + review + </div> + {structuredPreview.review_findings && ( + <div className="max-h-48 space-y-1 overflow-auto"> + {structuredPreview.review_findings.map((f, i) => ( + <div + key={i} + className="flex items-start gap-1.5 rounded bg-muted/20 px-2 py-1 text-[0.68rem]" + > + <span + className={`mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full ${ + f.severity === "error" || f.severity === "critical" + ? "bg-destructive" + : f.severity === "warning" + ? "bg-amber-500" + : "bg-muted-foreground/40" + }`} + /> + <div className="min-w-0 flex-1"> + <span className="leading-snug text-foreground/85"> + {f.message} + </span> + {(f.file || f.line) && ( + <span className="ml-1.5 text-[0.58rem] text-muted-foreground/60"> + {f.file} + {f.line ? `:${f.line}` : ""} + </span> + )} + </div> + </div> + ))} + </div> + )} + {structuredPreview.review_summary && ( + <div className="mt-1.5 rounded bg-muted/30 px-2 py-1 text-[0.68rem] leading-relaxed text-muted-foreground/80"> + {structuredPreview.review_summary} + </div> + )} + </div> + )} + + {/* QA — passed/failed summary + checks list */} + {((structuredPreview?.qa_passed !== undefined && structuredPreview?.qa_passed !== null) || + (structuredPreview?.qa_failed !== undefined && structuredPreview?.qa_failed !== null) || + structuredPreview?.qa_checks || + structuredPreview?.qa_summary) && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + qa + </div> + {(structuredPreview.qa_passed !== undefined || + structuredPreview.qa_failed !== undefined) && ( + <div className="mb-1.5 flex gap-2"> + {structuredPreview.qa_passed !== undefined && ( + <span className="inline-flex items-center gap-1 rounded border border-emerald-500/30 bg-emerald-500/[0.06] px-1.5 py-0.5 text-[0.62rem] font-medium text-emerald-500"> + <Check className="h-2.5 w-2.5" /> + {structuredPreview.qa_passed} passed + </span> + )} + {structuredPreview.qa_failed !== undefined && structuredPreview.qa_failed > 0 && ( + <span className="inline-flex items-center gap-1 rounded border border-destructive/30 bg-destructive/[0.06] px-1.5 py-0.5 text-[0.62rem] font-medium text-destructive"> + {structuredPreview.qa_failed} failed + </span> + )} + </div> + )} + {structuredPreview.qa_checks && structuredPreview.qa_checks.length > 0 && ( + <div className="max-h-40 space-y-0.5 overflow-auto"> + {structuredPreview.qa_checks.map((c, i) => ( + <div + key={i} + className="flex items-start gap-1.5 rounded bg-muted/20 px-2 py-0.5 text-[0.68rem]" + > + <span + className={`mt-0.5 h-3 w-3 shrink-0 ${ + c.passed ? "text-emerald-500" : "text-destructive" + }`} + > + {c.passed ? <Check /> : <Zap />} + </span> + <div className="min-w-0 flex-1"> + <span className="leading-snug text-foreground/85"> + {c.name} + </span> + {c.detail && ( + <span className="ml-1.5 text-[0.58rem] text-muted-foreground/60"> + {c.detail} + </span> + )} + </div> + </div> + ))} + </div> + )} + {structuredPreview.qa_summary && ( + <div className="mt-1.5 rounded bg-muted/30 px-2 py-1 text-[0.68rem] leading-relaxed text-muted-foreground/80"> + {structuredPreview.qa_summary} + </div> + )} + </div> + )} + + {/* Artifact */} + {(structuredPreview?.artifact_title || + structuredPreview?.artifact_path || + structuredPreview?.artifact_url || + structuredPreview?.artifact_content || + structuredPreview?.artifact_preview) && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + artifact + </div> + <div className="rounded border border-border/50 bg-muted/30 px-2 py-1.5"> + {structuredPreview.artifact_title && ( + <div className="text-[0.7rem] font-medium text-foreground/90"> + {structuredPreview.artifact_title} + </div> + )} + {structuredPreview.artifact_path && ( + <div className="mt-0.5 flex items-center gap-1 text-[0.6rem] text-muted-foreground/70"> + <FileText className="h-2.5 w-2.5 shrink-0" /> + <span className="truncate"> + {structuredPreview.artifact_path} + </span> + </div> + )} + {structuredPreview.artifact_url && ( + <div className="mt-0.5 flex items-center gap-1 text-[0.6rem]"> + <span className="shrink-0 text-muted-foreground/50">url</span> + <a + href={structuredPreview.artifact_url} + target="_blank" + rel="noopener noreferrer" + className="truncate text-primary/80 underline-offset-2 hover:underline" + > + {structuredPreview.artifact_url} + </a> + </div> + )} + {(structuredPreview.artifact_content || + structuredPreview.artifact_preview) && ( + <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted/40 px-2 py-1 text-[0.68rem] leading-relaxed text-muted-foreground/80"> + {structuredPreview.artifact_preview || + structuredPreview.artifact_content} + </pre> + )} + </div> + </div> + )} + + {/* ── Generic fallback (when no structured data) ── */} + + {/* Generic diff — only if structured didn't already show it */} + {!structuredPreview?.unified_diff && selectedTool.inline_diff && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + diff + </div> + <pre className="max-h-48 overflow-auto whitespace-pre rounded bg-muted/30 px-2 py-1.5 text-[0.7rem] leading-snug"> + {selectedTool.inline_diff.split("\n").map((line, i) => ( + <div + key={i} + className={ + line.startsWith("+") && !line.startsWith("+++") + ? "text-emerald-500" + : line.startsWith("-") && !line.startsWith("---") + ? "text-destructive" + : line.startsWith("@@") + ? "text-primary" + : "text-muted-foreground/80" + } + > + {line || "\u00A0"} + </div> + ))} + </pre> + </div> + )} + + {/* Generic result */} + {!structuredPreview && selectedTool.summary && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + result + </div> + <pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted/30 px-2 py-1.5 text-[0.7rem] leading-relaxed text-foreground/90"> + {selectedTool.summary} + </pre> + </div> + )} + + {/* Streaming preview (running tools) */} + {selectedTool.preview && selectedTool.status === "running" && ( + <div> + <div className="mb-1 flex items-center gap-1.5 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + streaming + <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse" /> + </div> + <pre className="max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted/30 px-2 py-1.5 text-[0.7rem] leading-relaxed text-muted-foreground"> + {selectedTool.preview} + </pre> + </div> + )} + + {/* Context last */} + {selectedTool.context && ( + <div> + <div className="mb-1 text-[0.6rem] uppercase tracking-[0.08em] text-muted-foreground/60"> + context + </div> + <pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded bg-muted/30 px-2 py-1.5 text-[0.7rem] leading-relaxed text-muted-foreground/80"> + {selectedTool.context} + </pre> + </div> + )} + </div> + </Card> + )} + {modelOpen && canPickModel && sessionId && ( <ModelPickerDialog gw={gw} diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx index c17a60d8eca2..15526999ddbf 100644 --- a/web/src/components/ToolCall.tsx +++ b/web/src/components/ToolCall.tsx @@ -37,8 +37,8 @@ export interface ToolEntry { } const STATUS_TONE: Record<ToolEntry["status"], string> = { - running: "border-primary/40 bg-primary/[0.04]", - done: "border-border bg-muted/20", + running: "border-primary/50 bg-primary/[0.03]", + done: "border-border/50 bg-transparent", error: "border-destructive/50 bg-destructive/[0.04]", }; @@ -50,7 +50,15 @@ const BULLET_TONE: Record<ToolEntry["status"], string> = { const TICK_MS = 500; -export function ToolCall({ tool }: { tool: ToolEntry }) { +export function ToolCall({ + tool, + onClick, + selected, +}: { + tool: ToolEntry; + onClick?: () => void; + selected?: boolean; +}) { // `open` is derived: errors default-expanded, everything else collapsed. // `null` means "follow the default"; any explicit bool is the user's override. // This lets a running tool flip to expanded automatically when it errors, @@ -86,11 +94,14 @@ export function ToolCall({ tool }: { tool: ToolEntry }) { return ( <div - className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`} + className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}${selected ? " ring-2 ring-primary/40" : ""}`} > <ListItem - onClick={() => setUserOverride(!open)} - disabled={!hasBody} + onClick={() => { + setUserOverride(!open); + onClick?.(); + }} + disabled={!hasBody && !onClick} aria-expanded={open} className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default" > diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index e35038483566..f62a73b2e6a5 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -109,6 +109,16 @@ function terminalLineHeightForWidth(layoutWidthPx: number): number { return layoutWidthPx < 1024 ? 1.02 : 1.15; } +/** Phase 4B: lightweight metadata only — no full DOM/selection/clipboard stored. */ +export interface BrowserContextRef { + id: string; + capturedAt: string; + url: string; + title: string; + pageType: string; + source: string; +} + export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const hostRef = useRef<HTMLDivElement | null>(null); const termRef = useRef<Terminal | null>(null); @@ -133,6 +143,8 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { ); const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null); + // Phase 4B: lightweight browser context ref set by browser-workspace plugin + const lastBrowserContextRef = useRef<BrowserContextRef | null>(null); // Raw state for the mobile side-sheet + a derived value that force- // closes whenever the chat tab isn't active. The *derived* value is // what side-effects (body-scroll lock, keydown listener, portal render) @@ -682,10 +694,26 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }); })(); + // Expose insertion API for browser workspace plugin (Phase 4A). + // Uses term.paste() so text flows through the same bracketed-paste + // path as user clipboard paste, landing in the TUI composer. + window.__HERMES_INSERT_CHAT_TEXT__ = (text: string) => { + const t = termRef.current; + if (t) t.paste(text); + }; + + // Phase 4B: accept lightweight browser context ref from plugin. + // Only metadata (id, url, title, capturedAt) is stored — no DOM/selection/clipboard. + window.__HERMES_SET_BROWSER_CONTEXT_REF__ = (ref: BrowserContextRef) => { + lastBrowserContextRef.current = ref; + }; + term.focus(); return () => { unmounting = true; + delete window.__HERMES_INSERT_CHAT_TEXT__; + delete window.__HERMES_SET_BROWSER_CONTEXT_REF__; syncMetricsRef.current = null; onDataDisposable?.dispose(); onResizeDisposable?.dispose(); @@ -933,5 +961,9 @@ declare global { interface Window { __HERMES_SESSION_TOKEN__?: string; __HERMES_AUTH_REQUIRED__?: boolean; + /** Phase 4A: Insert text into the chat PTY composer via xterm.paste(). */ + __HERMES_INSERT_CHAT_TEXT__?: (text: string) => void; + /** Phase 4B: Accept lightweight browser context ref (metadata only). */ + __HERMES_SET_BROWSER_CONTEXT_REF__?: (ref: BrowserContextRef) => void; } } diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index 50ad3261a3ff..5563c14b8fb8 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -51,6 +51,8 @@ import { Badge } from "@nous-research/ui/ui/components/badge"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +import { Link } from "react-router-dom"; +import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags"; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -638,6 +640,43 @@ export default function ConfigPage() { </div> </div> )} + {isDashboardEmbeddedChatEnabled() && ( + <Card> + <CardHeader className="py-3 px-4"> + <CardTitle className="text-sm flex items-center gap-2"> + <History className="h-4 w-4" /> + Diagnostics & History + </CardTitle> + </CardHeader> + <CardContent className="px-4 pb-4"> + <p className="text-[0.68rem] text-muted-foreground mb-3 leading-relaxed"> + Advanced pages for troubleshooting and administration. These + are not part of the default chat experience. + </p> + <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-1.5"> + {([ + ["/operations", "Operations", "Activity"], + ["/sessions", "Sessions", "MessageSquare"], + ["/analytics", "Analytics", "BarChart3"], + ["/models", "Models", "Cpu"], + ["/delegations", "Delegations", "Activity"], + ["/runs", "Runs", "Play"], + ["/logs", "Logs", "FileText"], + ] as const).map(([path, label]) => ( + <Link + key={path} + to={path} + className="flex items-center gap-1.5 rounded border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[0.68rem] text-muted-foreground transition-colors hover:border-midground/30 hover:text-midground hover:bg-midground/5" + > + <span className="text-[0.6rem] uppercase tracking-[0.08em]"> + {label} + </span> + </Link> + ))} + </div> + </CardContent> + </Card> + )} <PluginSlot name="config:bottom" /> <ConfirmDialog open={confirmReset}